From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@protonmail.com>
Date: Mon, 12 Oct 2026 17:55:05 -0300
Subject: [PATCH] agent_sandbox: run the Claude agent adapters inside ai-jail,
 set from Settings

Running the Claude agent jailed meant hand-writing a second agent_servers
entry whose command was `ai-jail ... claude-agent-acp-plus`, keeping its
maps and hosts in sync by hand, and nothing in Zeo said whether a thread
was jailed at all. A forgotten flag (no --map .git) left .git/hooks
writable, which runs code on the host at the next commit. This patch makes
the jail a setting: Settings > AI > Agent Sandbox (ai-jail), per project,
and Zeo builds the ai-jail command itself. Story 043.

What it adds:

  * crate agent_sandbox: settings `agent_sandbox` {enabled (default false),
    ai_jail_path, claude_config_dir (~/.claude-jailed), rw_maps,
    allow_hosts, projects{<root>: enabled/rw_maps/allow_hosts}} and
    `agent_memory` {enabled, endpoint}; validation; the ai-jail version
    probe (>= 2.4.0, else a GHSA warning; --forward-port detected from
    --help); the command generator; the launch decision; the confirmation
    prompts; an ai-memory MCP client (status, recent pages, forget).
  * agent_servers: a spawn hook. For a local project with the sandbox on,
    a Claude adapter (claude-agent-acp-plus or claude-agent-tui, matched by
    id, file name, resolved path, shell words or the @lucascouts package)
    starts as `ai-jail --claude-dir <dir> --map <root>/.git --map
    <root>/.zed --rw-map ... --allow-host ... --env NAME ... -- <agent>`,
    with the canonical project root as its cwd. The `--` keeps an agent
    argument that is also a jail flag from being read by ai-jail.
  * When ai-memory's port is forwarded, the agent runs behind a no_proxy
    wrapper: `-- /bin/sh -c '<export no_proxy/NO_PROXY; exec "$@">' sh
    <agent>`. ai-jail's filtered egress exports its own proxy and empties
    no_proxy after every --env, so loopback would go to the proxy; the
    exclusion (loopback plus any configured no_proxy) travels in
    ZEO_NO_PROXY, forwarded by name and unset before exec. Inside the
    jail loopback reaches only ai-jail's own bridges, so this opens no
    route the proxy guards. The jail therefore needs /bin/sh.
  * agent_ui: a lock icon on the thread toolbar (jailed, off, own command,
    outdated, settings changed since start); palette commands `agent
    sandbox: toggle`, `agent memory: status`, `agent memory: open project
    memory`. settings_ui: the Agent Sandbox and Agent Memory pages, with
    the generated command as a preview. Every control is reachable with
    Tab, and the page scrolls the focused control into view. The toolbar
    lock is not a Tab stop, like upstream's own sandbox icon.

Security boundary -- the off switch stays out of the agent's reach:

  * Both settings exist only in the user file; the same key in a
    project's .zed/settings.json reaches no value (Zeo logs a warning).
  * ai-jail's private home and /run tmpfs hide ~/.config/zeo and
    $XDG_RUNTIME_DIR (settings.json, the devtools bridge socket, D-Bus).
  * <root>/.git and <root>/.zed are mapped read-only by absolute path, so
    the agent can neither plant a hook nor project settings unjailed Zeo
    would act on. ~/.claude stays unmapped (claude_config_dir instead).
  * Writable folders are refused when they re-expose a protected path:
    the config dir, ~/.claude, ~/.claude.json, ~/.ssh, ~/.gnupg, /, home,
    $XDG_RUNTIME_DIR, and every target a symlink inside them points to
    (the walk fails closed past its limits); also refused: nested maps,
    a ':' or non-UTF-8 path (ai-jail reads --map as SOURCE:DEST), and a
    project root with either (cause=root_unusable).
  * Turning the jail off, from the page, the list or the palette, always
    asks first; Cancel is the first, preselected button, so a stray Enter
    keeps it on. Turning it on does not ask.
  * Only env names configured for the agent (its settings env, the
    registry/npx distribution env) are forwarded, by name and never as
    NAME=VALUE on argv; ai-jail's own environment is cleared to Zeo's
    PATH/LANG/XDG_RUNTIME_DIR/HOME (plus ZEO_NO_PROXY when ai-memory is
    forwarded), so shell proxies, tokens and AI_JAIL_*/BWRAP_BIN never
    reach or steer it. A shell-only
    ANTHROPIC_API_KEY must move into the agent's settings env.
  * The ai-memory token goes only into an HTTP header, never on argv or in
    a log; only a loopback endpoint's port is forwarded into the jail.

Fail closed: with the sandbox on and no usable jail (ai-jail missing or
unreadable, an invalid setting, a symlinked .git/.zed, a missing root),
the agent does not start. The thread shows the reason and "The agent was
not started without the sandbox."; there is no unjailed fallback.

Scope: the Claude adapters only. Zed's native agent, other ACP agents
and MCP servers Zeo starts are not wrapped. Remote projects are
unchanged. An entry whose command is already ai-jail is left alone and
shown as "jailed by its own command".

Known gaps:

  * terminal_auth_task (the agent's login command) runs unjailed.
  * The agent can create sub/.git, or .git when the project has none,
    with hooks inside the writable project; they run when the user
    commits there.
  * Any binary named ai-jail counts as the agent's own sandbox.
  * An ai-jail upgrade or PATH change after start (binary drift) is not
    shown on the icon; settings and memory drift are.

Packaging: USE ai-jail (default off), its own series group after
devtools-bridge and before test, marked `# @default: off` so the portable
builds (zeo-bin, .deb/.rpm, AppImage, Flatpak) leave it out. New crate
agent_sandbox; no new third-party dependency.

Tests: agent_sandbox unit and gpui tests (validation hostile and benign,
argv shape, version probe, launch decision, prompts, scope resolution,
ai-memory fixtures) plus tests/real_ai_jail.rs against a real ai-jail;
agent_servers spawn-hook and lifecycle tests; agent_ui icon tests;
settings_ui page tests.

The lifecycle tests also hold 0061's: the jail test requires ZEO_THREAD both
in ai-jail's own environment and as `--env ZEO_THREAD`, since ai-jail passes
on only the names given that way, and unjailed_agent_carries_the_zeo_thread_marker
checks an unjailed start. They live here because this fixture is the one that
spawns a real agent script and reads back its environment (story 050).

ACP v2 (0.6.1_p20261021). Upstream fae8a35482 moved `connect` into acp.rs;
with acp-beta on -- 0001 forces it in every Zeo build -- it tries ACP v2
first and falls back to v1 when the agent refuses the version. Its v2 attempt
spawned through the login shell, in the project's first folder, with Zeo's
whole environment, and dropped the launch's io_ended signal: a jailed start
leaked SHELL and every other variable into ai-jail, and the launch registry
lost its record while the agent still ran. AcpV2Connection::stdio_with now
takes the same StdioLaunch as v1 -- the override cwd, a cleared env, no login
shell. The v2 attempt holds an end signal of its own, forwarded to the
launch's only when the v2 connection is kept; on a v1 fallback the refused
process is dropped and the record follows the v1 process. Tests:
sandbox_hook_v2_tests -- a v2 start spawns once, in the root, with the
cleared env, and its record ends with the connection; a v1 fallback spawns
twice, both cleared, the refused process ends and the record follows the
live one.

a5c4ca8b review (2026-10-25, story 061): upstream's only Linux sandbox change
is in AcpThread::create_terminal, which builds the shell non_interactive() when a
sandbox_wrap is present. That wrap is Zed's own sandbox for the native agent's
terminal tool (passed from agent.rs, applied in acp_thread's terminal.rs); the
terminal and terminal_view diffs only move to Modifiers::secondary(cx). This
patch jails the adapter process at spawn and leaves the native agent out of
scope, so the two never meet: argv, cleared env, v2 spawn and fail-closed are
unchanged. Terminals an ACP agent asks Zeo to create still run outside the jail,
as before. No code change needed.
---

diff --git a/Cargo.lock b/Cargo.lock
index 36909b9..086c2f6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -371,6 +371,39 @@ dependencies = [
  "strum 0.28.0",
 ]
 
+[[package]]
+name = "agent_sandbox"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "context_server",
+ "fs",
+ "futures 0.3.34",
+ "gpui",
+ "http_client",
+ "log",
+ "parking_lot",
+ "paths",
+ "project",
+ "proto",
+ "regex",
+ "release_channel",
+ "remote",
+ "semver",
+ "serde",
+ "serde_json",
+ "serde_json_lenient",
+ "settings",
+ "shlex 2.0.1",
+ "smol",
+ "tempfile",
+ "toml 0.9.8",
+ "url",
+ "which",
+ "workspace",
+]
+
 [[package]]
 name = "agent_servers"
 version = "0.1.0"
@@ -378,6 +411,7 @@ dependencies = [
  "acp_thread",
  "action_log",
  "agent-client-protocol",
+ "agent_sandbox",
  "anyhow",
  "async-channel 2.5.0",
  "base64 0.22.1",
@@ -395,10 +429,12 @@ dependencies = [
  "indoc",
  "language_model",
  "log",
+ "paths",
  "project",
  "release_channel",
  "remote",
  "reqwest_client",
+ "semver",
  "serde",
  "serde_json",
  "settings",
@@ -410,6 +446,7 @@ dependencies = [
  "util",
  "uuid",
  "watch",
+ "workspace",
  "zed_credentials_provider",
 ]
 
@@ -491,6 +528,7 @@ dependencies = [
  "action_log",
  "agent",
  "agent-client-protocol",
+ "agent_sandbox",
  "agent_servers",
  "agent_settings",
  "agent_skills",
@@ -16524,6 +16562,7 @@ name = "settings_ui"
 version = "0.1.0"
 dependencies = [
  "agent",
+ "agent_sandbox",
  "agent_settings",
  "agent_skills",
  "anyhow",
@@ -16557,6 +16596,7 @@ dependencies = [
  "language_models",
  "log",
  "menu",
+ "parking_lot",
  "paths",
  "picker",
  "platform_title_bar",
@@ -16567,13 +16607,16 @@ dependencies = [
  "rodio",
  "schemars 1.0.4",
  "search",
+ "semver",
  "serde",
  "serde_json",
+ "serde_json_lenient",
  "serde_yaml_ng",
  "settings",
  "shell_command_parser",
  "strum 0.28.0",
  "telemetry",
+ "tempfile",
  "theme",
  "theme_settings",
  "ui",
@@ -22526,6 +22569,7 @@ dependencies = [
  "activity_indicator",
  "agent",
  "agent-client-protocol",
+ "agent_sandbox",
  "agent_servers",
  "agent_settings",
  "agent_skills",
diff --git a/Cargo.toml b/Cargo.toml
index b84fb16..d073360 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ members = [
     "crates/action_log",
     "crates/activity_indicator",
     "crates/agent",
+    "crates/agent_sandbox",
     "crates/agent_servers",
     "crates/agent_settings",
     "crates/agent_skills",
@@ -293,6 +294,7 @@ acp_tools = { path = "crates/acp_tools" }
 action_log = { path = "crates/action_log" }
 activity_indicator = { path = "crates/activity_indicator" }
 agent = { path = "crates/agent" }
+agent_sandbox = { path = "crates/agent_sandbox" }
 agent_servers = { path = "crates/agent_servers" }
 agent_settings = { path = "crates/agent_settings" }
 agent_skills = { path = "crates/agent_skills" }
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 4d73b15..90f7422 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -3137,4 +3137,26 @@
   "devtools_bridge": {
     "enabled": true,
   },
+  // Run the Claude agent adapters inside ai-jail. Read from the user settings
+  // file only: the same key in a project's `.zed/settings.json` is ignored.
+  "agent_sandbox": {
+    // Whether projects without an entry in `projects` run their agent jailed.
+    "enabled": false,
+    // The ai-jail binary. `null` looks it up on `PATH`.
+    "ai_jail_path": null,
+    // The Claude config directory mapped into the jail, instead of `~/.claude`.
+    "claude_config_dir": "~/.claude-jailed",
+    // Writable folders every jailed project gets, before its own.
+    "rw_maps": [],
+    // Hosts every jailed project may reach, before its own.
+    "allow_hosts": ["anthropic.com", "platform.claude.com"],
+    // Per-project overrides, keyed by the project's absolute root path:
+    // "/home/me/src/app": { "enabled": true, "rw_maps": [], "allow_hosts": [] }
+    "projects": {},
+  },
+  // The ai-memory server the agents talk to. Read from the user settings file only.
+  "agent_memory": {
+    "enabled": true,
+    "endpoint": "http://127.0.0.1:49374/mcp",
+  },
 }
diff --git a/crates/agent_sandbox/Cargo.toml b/crates/agent_sandbox/Cargo.toml
new file mode 100644
index 0000000..c5608cd
--- /dev/null
+++ b/crates/agent_sandbox/Cargo.toml
@@ -0,0 +1,50 @@
+[package]
+name = "agent_sandbox"
+version = "0.1.0"
+edition.workspace = true
+publish = false
+license = "GPL-3.0-or-later"
+description = "Runs the Claude agent adapters inside ai-jail, with settings read from the user file only"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/agent_sandbox.rs"
+doctest = false
+
+[dependencies]
+anyhow.workspace = true
+context_server.workspace = true
+futures.workspace = true
+gpui.workspace = true
+http_client.workspace = true
+log.workspace = true
+paths.workspace = true
+project.workspace = true
+regex.workspace = true
+semver.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+shlex.workspace = true
+smol.workspace = true
+toml.workspace = true
+url.workspace = true
+which.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+collections.workspace = true
+fs = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+http_client = { workspace = true, features = ["test-support"] }
+parking_lot.workspace = true
+project = { workspace = true, features = ["test-support"] }
+proto.workspace = true
+release_channel.workspace = true
+remote = { workspace = true, features = ["test-support"] }
+serde_json_lenient.workspace = true
+settings = { workspace = true, features = ["test-support"] }
+tempfile.workspace = true
+workspace = { workspace = true, features = ["test-support"] }
diff --git a/crates/agent_sandbox/src/agent_sandbox.rs b/crates/agent_sandbox/src/agent_sandbox.rs
new file mode 100644
index 0000000..834a01d
--- /dev/null
+++ b/crates/agent_sandbox/src/agent_sandbox.rs
@@ -0,0 +1,52 @@
+//! Runs the Claude agent adapters inside ai-jail.
+//!
+//! The `agent_sandbox` and `agent_memory` settings are fields of the user-level `SettingsContent`
+//! only, so a project's `.zed/settings.json` can neither switch the jail on or off nor turn
+//! memory off. [`init`] also watches each local project's settings file and logs one warning
+//! when it carries either key, so the ignored key does not go unnoticed.
+
+pub mod command;
+pub mod confirm;
+pub mod launch;
+pub mod memory;
+pub mod probe;
+mod project_keys;
+mod settings;
+pub mod validate;
+
+pub use crate::launch::LaunchRegistry;
+pub use crate::settings::{
+    AgentMemorySettings, AgentSandboxProjectSettings, AgentSandboxSettings, ProjectPolicy,
+    lexical_match, resolve_project_key,
+};
+
+use gpui::App;
+
+/// Sets up the launch registry and the project settings-file warning. Call once at startup.
+///
+/// Both settings register themselves through `RegisterSetting`.
+pub fn init(cx: &mut App) {
+    cx.set_global(LaunchRegistry::default());
+    project_keys::init(cx);
+}
+
+#[cfg(test)]
+mod command_tests;
+#[cfg(test)]
+mod confirm_tests;
+#[cfg(test)]
+mod launch_tests;
+#[cfg(test)]
+mod memory_tests;
+#[cfg(test)]
+mod probe_tests;
+#[cfg(test)]
+mod roundtrip_tests;
+#[cfg(test)]
+mod scope_tests;
+#[cfg(test)]
+mod settings_tests;
+#[cfg(test)]
+mod test_log_capture;
+#[cfg(test)]
+mod validate_tests;
diff --git a/crates/agent_sandbox/src/command.rs b/crates/agent_sandbox/src/command.rs
new file mode 100644
index 0000000..acb776d
--- /dev/null
+++ b/crates/agent_sandbox/src/command.rs
@@ -0,0 +1,175 @@
+//! Builds the ai-jail command that wraps a Claude adapter, and its read-only preview.
+//!
+//! Pure: no I/O, no settings reads. Every input is validated by the caller (`validate`, `probe`)
+//! before it reaches [`generate`].
+
+use std::borrow::Cow;
+use std::path::{Path, PathBuf};
+
+use project::agent_server_store::AgentServerCommand;
+
+/// Everything [`generate`] needs besides the agent's own command. All paths are validated.
+pub struct JailInputs<'a> {
+    /// The ai-jail binary, located and version-checked by `probe`.
+    pub ai_jail: &'a Path,
+    /// The project root the agent is spawned in. ai-jail binds its cwd as the writable project,
+    /// so this must be the spawn's working directory; `.git` and `.zed` are mapped read-only from
+    /// it by absolute path, which survives a shell `cd` before ai-jail starts.
+    pub project_root: &'a Path,
+    /// The dedicated Claude config directory; validated and known to exist.
+    pub claude_dir: &'a Path,
+    /// Writable folders: defaults united with the project's entries, deduplicated, order kept.
+    pub rw_maps: &'a [PathBuf],
+    /// Hosts the jail may reach.
+    pub allow_hosts: &'a [String],
+    /// Loopback ports forwarded into the jail (ai-memory, R7). Empty when unsupported or off.
+    pub forward_ports: &'a [u16],
+    /// Names of the agent's environment variables. Values never reach argv.
+    pub env_names: Vec<String>,
+}
+
+/// Wraps `agent` in ai-jail.
+///
+/// The result runs `inputs.ai_jail` with the jail options, then `--`, then the agent's path and
+/// arguments unchanged. `env` is passed through untouched: ai-jail copies each `--env NAME` from
+/// its own process environment, so no value is ever on the command line (R1.4). The `--` is
+/// required: without it ai-jail refuses an agent argument that is also one of its flags, and drops
+/// the agent's own `--` (measured on 2.8.1 and 2.7.0). `--network` (incompatible with
+/// `--forward-port`) and `--agent-state` (no mount for these adapters) are never emitted.
+pub fn generate(agent: AgentServerCommand, inputs: &JailInputs) -> AgentServerCommand {
+    let claude_dir = path_arg(inputs.claude_dir);
+    let mut args: Vec<String> = [
+        // `--exec` first: without it ai-jail interposes a PTY proxy that corrupts the ACP stream.
+        "--exec",
+        "--no-save-config",
+        "--no-status-bar",
+        "--no-toolchains",
+    ]
+    .into_iter()
+    .map(String::from)
+    .collect();
+    for read_only in [".git", ".zed"] {
+        args.push("--map".into());
+        args.push(path_arg(&inputs.project_root.join(read_only)));
+    }
+    args.extend(["--claude-dir".into(), claude_dir.clone()]);
+    args.extend(["--rw-map".into(), claude_dir]);
+    for map in inputs.rw_maps {
+        args.extend(["--rw-map".into(), path_arg(map)]);
+    }
+    for host in inputs.allow_hosts {
+        args.extend(["--allow-host".into(), host.clone()]);
+    }
+    for port in inputs.forward_ports {
+        args.extend(["--forward-port".into(), port.to_string()]);
+    }
+    let mut env_names = inputs.env_names.clone();
+    env_names.sort();
+    for name in env_names {
+        args.extend(["--env".into(), name]);
+    }
+    args.push("--".into());
+    args.push(path_arg(&agent.path));
+    args.extend(agent.args);
+
+    AgentServerCommand {
+        path: inputs.ai_jail.to_path_buf(),
+        args,
+        env: agent.env,
+    }
+}
+
+/// The variable that carries the proxy exclusion into the jail, set in ai-jail's environment by
+/// the launch and forwarded with `--env` by [`keep_loopback_off_proxy`].
+pub const NO_PROXY_CARRIER: &str = "ZEO_NO_PROXY";
+
+/// The loopback names a forwarded port is reached through inside the jail.
+const LOOPBACK: [&str; 3] = ["127.0.0.1", "localhost", "::1"];
+
+/// Restores the loopback proxy exclusion inside the jail: exports `no_proxy` and `NO_PROXY` from
+/// the carrier (loopback alone when it is missing), drops the carrier, and execs the agent.
+const NO_PROXY_SCRIPT: &str = concat!(
+    r#"export no_proxy="${ZEO_NO_PROXY:-127.0.0.1,localhost,::1}" "#,
+    r#"NO_PROXY="${ZEO_NO_PROXY:-127.0.0.1,localhost,::1}"; "#,
+    r#"unset ZEO_NO_PROXY; exec "$@""#
+);
+
+/// The proxy exclusion a jail with a forwarded port needs: the loopback names first, then every
+/// entry of `configured` (comma lists, such as a configured `no_proxy` and `NO_PROXY`), trimmed,
+/// with empty and repeated entries dropped.
+pub fn loopback_no_proxy<'a>(configured: impl IntoIterator<Item = &'a str>) -> String {
+    let mut entries: Vec<&str> = LOOPBACK.to_vec();
+    for entry in configured
+        .into_iter()
+        .flat_map(|list| list.split(','))
+        .map(str::trim)
+    {
+        if !entry.is_empty() && !entries.contains(&entry) {
+            entries.push(entry);
+        }
+    }
+    entries.join(",")
+}
+
+/// Keeps loopback off ai-jail's egress proxy for a [`generate`]d command that forwards a port.
+///
+/// With filtered egress ai-jail exports its own `http_proxy`/`HTTPS_PROXY` and sets `no_proxy` and
+/// `NO_PROXY` empty after every `--env` (measured on the fork's 2.7.0), so a client honouring them
+/// sends `http://127.0.0.1:<port>` to the proxy instead of the forwarded port. A `--env no_proxy`
+/// cannot win; the agent is therefore started through `/bin/sh`, which exports the exclusion from
+/// [`NO_PROXY_CARRIER`] (forwarded here by name, so no value reaches argv) and then execs the agent
+/// with its arguments unchanged. Inside the jail's network namespace loopback reaches only
+/// ai-jail's own bridges, so the exclusion opens no route the proxy guards.
+pub fn keep_loopback_off_proxy(mut cmd: AgentServerCommand) -> AgentServerCommand {
+    // `generate` emits exactly one `--`, right before the agent; the jail options carry none.
+    let Some(separator) = cmd.args.iter().position(|arg| arg == "--") else {
+        return cmd;
+    };
+    let agent = cmd.args.split_off(separator + 1);
+    cmd.args.pop();
+    let carried = cmd
+        .args
+        .windows(2)
+        .any(|pair| pair[0] == "--env" && pair[1] == NO_PROXY_CARRIER);
+    if !carried {
+        cmd.args.extend(["--env".into(), NO_PROXY_CARRIER.into()]);
+    }
+    cmd.args.extend(
+        ["--", "/bin/sh", "-c", NO_PROXY_SCRIPT, "sh"]
+            .into_iter()
+            .map(String::from),
+    );
+    cmd.args.extend(agent);
+    cmd
+}
+
+/// Renders `cmd` as one shell line, each word quoted so that a shell split gives back the exact
+/// argv. Never reads `cmd.env`, so no value can appear (R3.7).
+pub fn preview(cmd: &AgentServerCommand) -> String {
+    let path = cmd.path.to_string_lossy();
+    std::iter::once(path.as_ref())
+        .chain(cmd.args.iter().map(String::as_str))
+        .map(quote)
+        .collect::<Vec<_>>()
+        .join(" ")
+}
+
+/// A path as an argv word.
+///
+/// Invariant: every jail path reaching [`generate`] is UTF-8, because `validate` refuses a
+/// canonical path that is not, so the lossy conversion never alters one. The agent path is not
+/// validated there; a non-UTF-8 one would become a path that does not exist, and ai-jail would
+/// fail to start the agent (closed, never widened). Lossy instead of `expect`: a broken invariant
+/// degrades to a visibly wrong path, not a panic.
+fn path_arg(path: &Path) -> String {
+    path.to_string_lossy().into_owned()
+}
+
+/// Shell-quotes one word. `shlex` refuses only a NUL byte, which no shell word can carry; the
+/// fallback single-quotes the word and shows each NUL as `\0`, so the preview stays readable.
+fn quote(word: &str) -> Cow<'_, str> {
+    shlex::try_quote(word).unwrap_or_else(|_| {
+        let escaped = word.replace('\'', r"'\''").replace('\0', r"\0");
+        Cow::Owned(format!("'{escaped}'"))
+    })
+}
diff --git a/crates/agent_sandbox/src/command_tests.rs b/crates/agent_sandbox/src/command_tests.rs
new file mode 100644
index 0000000..66e0dd9
--- /dev/null
+++ b/crates/agent_sandbox/src/command_tests.rs
@@ -0,0 +1,325 @@
+// Story 043, task 2.3 — the generated jailed command and its preview (R1.1, R1.2, R1.3, R1.4,
+// R3.7, R7.1, R7.2; Q15). Wired in the crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod command_tests;
+//
+// Validation filter: `command` (every test path is `command_tests::…`).
+//
+// Contract (design.md §agent_sandbox::command, quoted there as code). Assumed signatures — adjust
+// the `use` only:
+//   - `crate::command::JailInputs<'a> { ai_jail, project_root, claude_dir, rw_maps, allow_hosts, forward_ports, env_names }`
+//   - `crate::command::generate(agent: AgentServerCommand, inputs: &JailInputs) -> AgentServerCommand`
+//   - `crate::command::preview(cmd: &AgentServerCommand) -> String` (shell-quoted argv, no env values)
+//
+// Hostile halves first:
+//   - secrets, wrongly LEAK: an env value never appears in argv nor in the preview, even when the
+//     value looks like a flag; `--env NAME=VALUE` never appears.
+//   - flags, wrongly MERGE: agent arguments that look like ai-jail flags (`--rw-map /`,
+//     `--network`) stay after the agent path, untouched, behind a `--` that sits right before the
+//     agent path (measured on ai-jail 2.8.1 and 2.7.0: without it ai-jail refuses an agent argument
+//     that is also a jail flag); the jail options never gain `--network`, `--agent-state` or a `--`.
+//   - preview, wrongly SPLIT/JOIN: an argument with spaces, quotes or `$(…)` round-trips through
+//     a shell-word split to exactly one argument.
+
+use collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use project::agent_server_store::AgentServerCommand;
+
+use crate::command::{JailInputs, generate, preview};
+
+fn agent(env: Option<HashMap<String, String>>) -> AgentServerCommand {
+    AgentServerCommand {
+        path: PathBuf::from("/usr/bin/claude-agent-acp-plus"),
+        args: vec!["--acp".into(), "two words".into()],
+        env,
+    }
+}
+
+fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
+    pairs
+        .iter()
+        .map(|(k, v)| (k.to_string(), v.to_string()))
+        .collect()
+}
+
+fn argv(cmd: &AgentServerCommand) -> Vec<String> {
+    cmd.args.clone()
+}
+
+fn s(items: &[&str]) -> Vec<String> {
+    items.iter().map(|item| item.to_string()).collect()
+}
+
+#[test]
+fn command_never_puts_env_values_on_argv() {
+    let rw: [PathBuf; 0] = [];
+    let hosts: [String; 0] = [];
+    let agent_env = env(&[
+        ("ANTHROPIC_API_KEY", "sk-ant-secret-1"),
+        ("WEIRD", "--rw-map=/"),
+    ]);
+    let inputs = JailInputs {
+        ai_jail: Path::new("/usr/bin/ai-jail"),
+        project_root: Path::new("/w/app"),
+        claude_dir: Path::new("/h/.claude-jailed"),
+        rw_maps: &rw,
+        allow_hosts: &hosts,
+        forward_ports: &[],
+        env_names: vec!["ANTHROPIC_API_KEY".into(), "WEIRD".into()],
+    };
+    let jailed = generate(agent(Some(agent_env.clone())), &inputs);
+    let joined = jailed.args.join("\u{1f}");
+    assert!(
+        !joined.contains("sk-ant-secret-1"),
+        "no env value on argv: {:?}",
+        jailed.args
+    );
+    assert!(
+        !joined.contains("--rw-map=/"),
+        "no env value on argv: {:?}",
+        jailed.args
+    );
+    assert!(
+        jailed
+            .args
+            .iter()
+            .all(|arg| !(arg.contains('=') && arg.starts_with("ANTHROPIC"))),
+        "never NAME=VALUE"
+    );
+    assert_eq!(
+        jailed.env,
+        Some(agent_env),
+        "the values travel in the process environment"
+    );
+    let shown = preview(&jailed);
+    assert!(
+        !shown.contains("sk-ant-secret-1"),
+        "no env value in the preview: {shown}"
+    );
+    assert!(
+        shown.contains("ANTHROPIC_API_KEY"),
+        "the env name is shown: {shown}"
+    );
+}
+
+#[test]
+fn command_keeps_agent_args_that_look_like_jail_flags_after_the_agent() {
+    let rw: [PathBuf; 0] = [];
+    let hosts: [String; 0] = [];
+    let inputs = JailInputs {
+        ai_jail: Path::new("/usr/bin/ai-jail"),
+        project_root: Path::new("/w/app"),
+        claude_dir: Path::new("/h/.claude-jailed"),
+        rw_maps: &rw,
+        allow_hosts: &hosts,
+        forward_ports: &[],
+        env_names: vec![],
+    };
+    let mut cmd = agent(None);
+    cmd.args = s(&["--rw-map", "/", "--network"]);
+    let jailed = generate(cmd, &inputs);
+    let args = argv(&jailed);
+    let agent_at = args
+        .iter()
+        .position(|arg| arg == "/usr/bin/claude-agent-acp-plus")
+        .expect("the agent path is on argv");
+    assert_eq!(
+        &args[agent_at + 1..],
+        &s(&["--rw-map", "/", "--network"])[..]
+    );
+    assert_eq!(
+        args[agent_at - 1],
+        "--",
+        "a `--` separates the jail options from the agent: {args:?}"
+    );
+    let jail_part = &args[..agent_at - 1];
+    for forbidden in ["--network", "--agent-state", "--"] {
+        assert!(
+            !jail_part.iter().any(|arg| arg == forbidden),
+            "the jail options never carry {forbidden}: {jail_part:?}"
+        );
+    }
+    assert!(
+        !jail_part.iter().any(|arg| arg == "/"),
+        "the agent's `/` never becomes a jail --rw-map"
+    );
+}
+
+#[test]
+fn command_exact_argv_shape() {
+    let rw = [PathBuf::from("/h/cache"), PathBuf::from("/data")];
+    let hosts = [
+        "anthropic.com".to_string(),
+        "platform.claude.com".to_string(),
+    ];
+    let agent_env = env(&[("A_NAME", "1"), ("B_NAME", "2")]);
+    let inputs = JailInputs {
+        ai_jail: Path::new("/usr/bin/ai-jail"),
+        project_root: Path::new("/w/app"),
+        claude_dir: Path::new("/h/.claude-jailed"),
+        rw_maps: &rw,
+        allow_hosts: &hosts,
+        forward_ports: &[49374],
+        env_names: vec!["A_NAME".into(), "B_NAME".into()],
+    };
+    let jailed = generate(agent(Some(agent_env.clone())), &inputs);
+    assert_eq!(jailed.path, PathBuf::from("/usr/bin/ai-jail"));
+    assert_eq!(
+        jailed.args,
+        s(&[
+            "--exec",
+            "--no-save-config",
+            "--no-status-bar",
+            "--no-toolchains",
+            "--map",
+            "/w/app/.git",
+            "--map",
+            "/w/app/.zed",
+            "--claude-dir",
+            "/h/.claude-jailed",
+            "--rw-map",
+            "/h/.claude-jailed",
+            "--rw-map",
+            "/h/cache",
+            "--rw-map",
+            "/data",
+            "--allow-host",
+            "anthropic.com",
+            "--allow-host",
+            "platform.claude.com",
+            "--forward-port",
+            "49374",
+            "--env",
+            "A_NAME",
+            "--env",
+            "B_NAME",
+            "--",
+            "/usr/bin/claude-agent-acp-plus",
+            "--acp",
+            "two words",
+        ])
+    );
+    assert_eq!(
+        jailed.args[0], "--exec",
+        "--exec first: no PTY proxy on the ACP stream"
+    );
+    assert_eq!(jailed.env, Some(agent_env));
+}
+
+#[test]
+fn command_without_memory_ports_or_env_adds_neither() {
+    let rw: [PathBuf; 0] = [];
+    let hosts = ["anthropic.com".to_string()];
+    let inputs = JailInputs {
+        ai_jail: Path::new("/usr/bin/ai-jail"),
+        project_root: Path::new("/w/app"),
+        claude_dir: Path::new("/h/.claude-jailed"),
+        rw_maps: &rw,
+        allow_hosts: &hosts,
+        forward_ports: &[],
+        env_names: vec![],
+    };
+    let jailed = generate(agent(None), &inputs);
+    assert!(
+        !jailed.args.iter().any(|arg| arg == "--forward-port"),
+        "R7.2"
+    );
+    assert!(!jailed.args.iter().any(|arg| arg == "--env"));
+    assert_eq!(jailed.env, None);
+    for required in [
+        "--exec",
+        "--no-save-config",
+        "--no-status-bar",
+        "--no-toolchains",
+    ] {
+        assert!(
+            jailed.args.iter().any(|arg| arg == required),
+            "{required} always present"
+        );
+    }
+}
+
+#[test]
+fn command_preview_round_trips_hostile_arguments() {
+    let rw = [PathBuf::from("/h/my cache")];
+    let hosts: [String; 0] = [];
+    let inputs = JailInputs {
+        ai_jail: Path::new("/usr/bin/ai-jail"),
+        project_root: Path::new("/w/app"),
+        claude_dir: Path::new("/h/.claude-jailed"),
+        rw_maps: &rw,
+        allow_hosts: &hosts,
+        forward_ports: &[],
+        env_names: vec![],
+    };
+    let mut cmd = agent(None);
+    cmd.args = s(&["it's", "$(rm -rf ~)", "a\"b", "semi;colon", ""]);
+    let jailed = generate(cmd, &inputs);
+    let shown = preview(&jailed);
+    let words = shlex::split(&shown).expect("the preview is valid shell words");
+    let mut expected = vec![jailed.path.display().to_string()];
+    expected.extend(jailed.args.iter().cloned());
+    assert_eq!(words, expected, "preview: {shown}");
+}
+
+// Fix round of 10.3 (live proof, defect #1): the loopback proxy exclusion for a forwarded port.
+// Hostile halves: the carrier is forwarded once even when the agent's settings configure it too;
+// the agent and its jail-flag-looking arguments stay after the wrapper, unchanged; the merge never
+// drops a configured entry and never repeats one.
+#[test]
+fn command_keep_loopback_off_proxy_wraps_the_agent_once() {
+    use crate::command::{NO_PROXY_CARRIER, keep_loopback_off_proxy, loopback_no_proxy};
+
+    let rw: [PathBuf; 0] = [];
+    let hosts = ["anthropic.com".to_string()];
+    let inputs = JailInputs {
+        ai_jail: Path::new("/usr/bin/ai-jail"),
+        project_root: Path::new("/w/app"),
+        claude_dir: Path::new("/h/.claude-jailed"),
+        rw_maps: &rw,
+        allow_hosts: &hosts,
+        forward_ports: &[49374],
+        env_names: vec![NO_PROXY_CARRIER.into()],
+    };
+    let mut cmd = agent(None);
+    cmd.args = s(&["--rw-map", "/", "--"]);
+    let wrapped = keep_loopback_off_proxy(generate(cmd, &inputs));
+    let args = argv(&wrapped);
+    let separator = args.iter().position(|arg| arg == "--").expect("a `--`");
+    assert_eq!(
+        args[..separator]
+            .windows(2)
+            .filter(|pair| pair[0] == "--env" && pair[1] == NO_PROXY_CARRIER)
+            .count(),
+        1,
+        "{args:?}"
+    );
+    assert_eq!(
+        &args[separator + 1..separator + 3],
+        &s(&["/bin/sh", "-c"])[..]
+    );
+    assert_eq!(
+        &args[separator + 4..],
+        &s(&[
+            "sh",
+            "/usr/bin/claude-agent-acp-plus",
+            "--rw-map",
+            "/",
+            "--"
+        ])[..]
+    );
+    let words = shlex::split(&preview(&wrapped)).expect("the preview splits");
+    assert_eq!(
+        &words[1..],
+        &args[..],
+        "the preview round-trips the wrapper"
+    );
+
+    assert_eq!(loopback_no_proxy([]), "127.0.0.1,localhost,::1");
+    assert_eq!(
+        loopback_no_proxy([" a.example ,::1,,", "A.example,a.example"]),
+        "127.0.0.1,localhost,::1,a.example,A.example"
+    );
+}
diff --git a/crates/agent_sandbox/src/confirm.rs b/crates/agent_sandbox/src/confirm.rs
new file mode 100644
index 0000000..c39d399
--- /dev/null
+++ b/crates/agent_sandbox/src/confirm.rs
@@ -0,0 +1,570 @@
+//! The confirmations the agent sandbox asks for, and the only two writers of its switches.
+//!
+//! Turning the jail off, for one project or for the default, always goes through
+//! [`confirm_turn_off`]; turning it on saves without asking (R4.1–R4.4). The settings page and the
+//! palette both call [`set_project_enabled`] and [`set_default_enabled`] and never write the
+//! switches themselves.
+
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, OnceLock};
+
+use futures::channel::oneshot;
+use gpui::{App, AppContext as _, AsyncApp, PromptLevel, Task, Window};
+use project::Fs;
+use settings::{Settings as _, SettingsContent, SettingsStore};
+use workspace::notifications::simple_message_notification::MessageNotification;
+use workspace::notifications::{NotificationId, show_app_notification};
+
+use crate::settings::{AgentSandboxSettings, lexical_match, resolve_project_key};
+
+/// The R4.1 detail, shared by the project and the default prompt.
+pub const TURN_OFF_DETAIL: &str =
+    "The agent will run with your user's full access the next time it starts.";
+/// The R6.4 detail.
+pub const FORGET_DETAIL: &str = "ai-memory deletes the page. This cannot be undone.";
+/// Cancel comes first: Zed's prompt preselects the first button and Enter confirms it, so an Enter
+/// still held after the palette command lands on the harmless answer.
+pub const TURN_OFF_BUTTONS: [&str; 2] = ["Cancel", "Turn Off"];
+/// Cancel first, for the same reason as [`TURN_OFF_BUTTONS`].
+pub const FORGET_BUTTONS: [&str; 2] = ["Cancel", "Forget"];
+/// The index of the confirming button in both button arrays.
+const CONFIRM_INDEX: usize = 1;
+/// What the default switch's prompt names (R4.2).
+const DEFAULT_SCOPE: &str = "projects without their own setting";
+/// The settings key [`set_default_enabled`] writes, as logged.
+const DEFAULT_KEY: &str = "agent_sandbox.enabled";
+
+/// What a turn-off confirmation names.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum OffScope {
+    /// One project, by its display name.
+    Project(String),
+    /// The default switch, for projects without their own setting.
+    Default,
+}
+
+/// Asks "Turn off the agent sandbox for <scope>?" (R4.1, R4.2). `true` only for Turn Off; Cancel,
+/// a dismissed prompt and a dropped one are all `false` (R4.3).
+pub fn confirm_turn_off(scope: OffScope, window: &mut Window, cx: &mut App) -> Task<bool> {
+    let name = match &scope {
+        OffScope::Project(name) => name.as_str(),
+        OffScope::Default => DEFAULT_SCOPE,
+    };
+    let message = format!("Turn off the agent sandbox for {name}?");
+    ask(&message, TURN_OFF_DETAIL, &TURN_OFF_BUTTONS, window, cx)
+}
+
+/// Asks "Forget <path>?" (R6.4). `true` only for Forget.
+pub fn confirm_forget(path: &str, window: &mut Window, cx: &mut App) -> Task<bool> {
+    let message = format!("Forget {path}?");
+    ask(&message, FORGET_DETAIL, &FORGET_BUTTONS, window, cx)
+}
+
+fn ask(
+    message: &str,
+    detail: &str,
+    buttons: &[&str],
+    window: &mut Window,
+    cx: &mut App,
+) -> Task<bool> {
+    let answer = window.prompt(PromptLevel::Warning, message, Some(detail), buttons, cx);
+    cx.foreground_executor()
+        .spawn(async move { confirmed(answer.await) })
+}
+
+/// Only the second button confirms: the preselected first one, a dismissed prompt and a dropped
+/// one all count as Cancel.
+fn confirmed(answer: Result<usize, oneshot::Canceled>) -> bool {
+    answer == Ok(CONFIRM_INDEX)
+}
+
+/// Sets `agent_sandbox.projects[<root>].enabled`, asking first when `enabled` is `false`. Only
+/// `enabled` is touched; the entry's folders and hosts stay. Resolves to `true` only when the file
+/// was saved. A root that is not valid UTF-8 is refused before asking: no key could name it.
+pub fn set_project_enabled(
+    root: &Path,
+    enabled: bool,
+    window: &mut Window,
+    cx: &mut App,
+) -> Task<bool> {
+    let Some(root_text) = root.to_str().map(str::to_string) else {
+        refuse_non_utf8_root(root, cx);
+        return Task::ready(false);
+    };
+    let consent = if enabled {
+        Task::ready(true)
+    } else {
+        let name = root
+            .file_name()
+            .and_then(|name| name.to_str())
+            .unwrap_or(&root_text)
+            .to_string();
+        confirm_turn_off(OffScope::Project(name), window, cx)
+    };
+    let root = root.to_path_buf();
+    cx.spawn(async move |cx| {
+        if !consent.await {
+            return false;
+        }
+        let keys: Vec<String> = cx.update(|cx| {
+            AgentSandboxSettings::get_global(cx)
+                .projects
+                .keys()
+                .cloned()
+                .collect()
+        });
+        // Canonicalizing every key blocks, so it runs off the main thread.
+        let resolved = cx
+            .background_executor()
+            .spawn(async move {
+                resolve_project_key(keys.iter().map(String::as_str), &root).map(PathBuf::from)
+            })
+            .await;
+        let fallback = project_key_label(&root_text);
+        save(
+            cx,
+            move |content| {
+                let projects = content
+                    .agent_sandbox
+                    .get_or_insert_default()
+                    .projects
+                    .get_or_insert_default();
+                let key = target_project_key(projects.keys(), &root_text, resolved.as_deref());
+                let label = project_key_label(&key);
+                projects.entry(key).or_default().enabled = Some(enabled);
+                label
+            },
+            fallback,
+        )
+        .await
+    })
+}
+
+/// Sets `agent_sandbox.enabled`, the switch for projects without their own setting, asking first
+/// when `enabled` is `false`. Resolves to `true` only when the file was saved.
+pub fn set_default_enabled(enabled: bool, window: &mut Window, cx: &mut App) -> Task<bool> {
+    let consent = if enabled {
+        Task::ready(true)
+    } else {
+        confirm_turn_off(OffScope::Default, window, cx)
+    };
+    cx.spawn(async move |cx| {
+        if !consent.await {
+            return false;
+        }
+        save(
+            cx,
+            move |content| {
+                content.agent_sandbox.get_or_insert_default().enabled = Some(enabled);
+                DEFAULT_KEY.to_string()
+            },
+            DEFAULT_KEY.to_string(),
+        )
+        .await
+    })
+}
+
+/// The `projects` key to write for `root`: a key in the file that names it lexically
+/// ([`lexical_match`]), else `resolved` (the key [`resolve_project_key`] found through a symlink)
+/// when the file still holds it, else `root` as spelled. Writing beside an existing key instead
+/// would hide its folders and hosts behind a second entry.
+pub fn target_project_key<'a>(
+    keys: impl IntoIterator<Item = &'a String>,
+    root: &str,
+    resolved: Option<&Path>,
+) -> String {
+    let keys: Vec<&'a String> = keys.into_iter().collect();
+    if let Some(key) = lexical_match(keys.iter().map(|key| key.as_str()), Path::new(root)) {
+        return key.to_string();
+    }
+    let resolved = resolved.and_then(Path::to_str);
+    if let Some(key) = keys.iter().find(|key| Some(key.as_str()) == resolved) {
+        return key.to_string();
+    }
+    root.to_string()
+}
+
+/// The text shown when a root that is not valid UTF-8 is refused.
+pub fn non_utf8_root_message(root: &Path) -> String {
+    format!(
+        "The agent sandbox cannot be configured for a project whose path is not valid UTF-8: {}",
+        root.to_string_lossy()
+    )
+}
+
+fn refuse_non_utf8_root(root: &Path, cx: &mut App) {
+    log::warn!(
+        "agent_sandbox event=settings_write_refused reason=non_utf8_root root={:?}",
+        root.to_string_lossy()
+    );
+    notify(non_utf8_root_message(root), cx);
+}
+
+fn project_key_label(key: &str) -> String {
+    format!("agent_sandbox.projects[{key}].enabled")
+}
+
+/// Writes the user settings file through `update`, which returns the key it wrote for the log.
+/// `true` only when the file was saved; a failure is logged and shown, never dropped.
+async fn save(
+    cx: &mut AsyncApp,
+    update: impl 'static + Send + FnOnce(&mut SettingsContent) -> String,
+    fallback_key: String,
+) -> bool {
+    let written_key = Arc::new(OnceLock::new());
+    let receiver = cx.update(|cx| {
+        let written_key = written_key.clone();
+        cx.global::<SettingsStore>()
+            .update_settings_file_with_completion(<dyn Fs>::global(cx), move |content, _| {
+                written_key.set(update(content)).ok();
+            })
+    });
+    let error = match receiver.await {
+        Ok(Ok(())) => return true,
+        Ok(Err(error)) => error,
+        Err(oneshot::Canceled) => anyhow::anyhow!("the settings writer stopped before saving"),
+    };
+    let key = written_key.get().cloned().unwrap_or(fallback_key);
+    log::error!("{}", write_failure_log_line(&key, &error));
+    let message = format!(
+        "Could not save the agent sandbox setting: {}",
+        first_line(&error)
+    );
+    cx.update(|cx| notify(message, cx));
+    false
+}
+
+/// One `key=value` line (Q14): the key and the error's first line, both quoted.
+fn write_failure_log_line(key: &str, error: &anyhow::Error) -> String {
+    let error = first_line(error);
+    format!("agent_sandbox event=settings_write_failed key={key:?} error={error:?}")
+}
+
+fn first_line(error: &anyhow::Error) -> String {
+    format!("{error:#}")
+        .lines()
+        .next()
+        .unwrap_or_default()
+        .to_string()
+}
+
+/// A toast in every open workspace. The settings window is not a workspace, so this goes through
+/// the app-wide notifications.
+fn notify(message: String, cx: &mut App) {
+    struct AgentSandboxSettingsNotification;
+    show_app_notification(
+        NotificationId::unique::<AgentSandboxSettingsNotification>(),
+        cx,
+        move |cx| {
+            let message = message.clone();
+            cx.new(|cx| MessageNotification::new(message, cx))
+        },
+    );
+}
+
+#[cfg(test)]
+mod tests {
+    use std::cell::RefCell;
+    use std::path::Path;
+    use std::rc::Rc;
+    use std::sync::Arc;
+
+    use fs::{FakeFs, Fs};
+    use gpui::{
+        App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
+        PromptResponse, Render, TestAppContext, UpdateGlobal as _, VisualTestContext, Window, div,
+    };
+    use serde_json::{Value, json};
+    use settings::SettingsStore;
+
+    use super::{
+        FORGET_BUTTONS, TURN_OFF_BUTTONS, confirm_forget, non_utf8_root_message,
+        set_default_enabled, set_project_enabled, target_project_key, write_failure_log_line,
+    };
+    use crate::test_log_capture::LogCapture;
+
+    async fn init(user: Value, cx: &mut TestAppContext) -> Arc<FakeFs> {
+        let fs = FakeFs::new(cx.executor());
+        cx.update(|cx| {
+            let store = SettingsStore::test(cx);
+            cx.set_global(store);
+            <dyn Fs>::set_global(fs.clone(), cx);
+            crate::init(cx);
+        });
+        fs.create_dir(paths::config_dir())
+            .await
+            .expect("the config dir");
+        fs.insert_file(paths::settings_file(), user.to_string().into_bytes())
+            .await;
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store
+                    .set_user_settings(&user.to_string(), cx)
+                    .result()
+                    .expect("user settings parse");
+            })
+        });
+        cx.run_until_parked();
+        fs
+    }
+
+    async fn file_text(fs: &Arc<FakeFs>) -> String {
+        fs.load(paths::settings_file())
+            .await
+            .expect("settings file")
+    }
+
+    async fn projects(fs: &Arc<FakeFs>) -> serde_json::Map<String, Value> {
+        let file: Value =
+            serde_json_lenient::from_str(&file_text(fs).await).expect("settings JSON");
+        file["agent_sandbox"]["projects"]
+            .as_object()
+            .cloned()
+            .expect("a projects map")
+    }
+
+    /// A prompt that records its buttons and is answered by index, the way Zed's own prompt
+    /// answers Enter with its preselected first button.
+    struct ByIndex {
+        focus: FocusHandle,
+    }
+
+    impl EventEmitter<PromptResponse> for ByIndex {}
+
+    impl Focusable for ByIndex {
+        fn focus_handle(&self, _: &App) -> FocusHandle {
+            self.focus.clone()
+        }
+    }
+
+    impl Render for ByIndex {
+        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+            div()
+        }
+    }
+
+    #[derive(Default)]
+    struct Prompts {
+        buttons: Vec<Vec<String>>,
+        open: Option<Entity<ByIndex>>,
+    }
+
+    fn prompts_by_index(cx: &mut VisualTestContext) -> Rc<RefCell<Prompts>> {
+        let prompts = Rc::new(RefCell::new(Prompts::default()));
+        let shared = prompts.clone();
+        cx.update(|_, cx| {
+            cx.set_prompt_builder(move |_, _, _, buttons, handle, window, cx| {
+                let view = cx.new(|cx| ByIndex {
+                    focus: cx.focus_handle(),
+                });
+                let mut prompts = shared.borrow_mut();
+                prompts
+                    .buttons
+                    .push(buttons.iter().map(|b| b.label().to_string()).collect());
+                prompts.open = Some(view.clone());
+                handle.with_view(view, window, cx)
+            })
+        });
+        prompts
+    }
+
+    fn press(prompts: &Rc<RefCell<Prompts>>, index: usize, cx: &mut VisualTestContext) {
+        let view = prompts.borrow_mut().open.take().expect("an open prompt");
+        cx.update(|_, cx| view.update(cx, |_, cx| cx.emit(PromptResponse(index))));
+        cx.run_until_parked();
+    }
+
+    #[gpui::test]
+    async fn confirm_writer_updates_the_lexically_matching_key(cx: &mut TestAppContext) {
+        let fs = init(
+            json!({ "agent_sandbox": { "enabled": true, "projects": { "/w/app/": {
+                "enabled": false, "rw_maps": ["/w/cache"], "allow_hosts": ["example.org"]
+            } } } }),
+            cx,
+        )
+        .await;
+        let cx = cx.add_empty_window();
+        let task =
+            cx.update(|window, cx| set_project_enabled(Path::new("/w/app"), true, window, cx));
+        cx.run_until_parked();
+        assert!(task.await, "saved");
+        let projects = projects(&fs).await;
+        assert_eq!(
+            projects.keys().collect::<Vec<_>>(),
+            ["/w/app/"],
+            "no second key"
+        );
+        assert_eq!(
+            projects["/w/app/"],
+            json!({ "enabled": true, "rw_maps": ["/w/cache"], "allow_hosts": ["example.org"] }),
+            "only enabled changes"
+        );
+    }
+
+    #[gpui::test]
+    async fn confirm_writer_updates_the_key_a_symlinked_root_resolves_to(cx: &mut TestAppContext) {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let base = tmp.path().canonicalize().expect("canonical");
+        let real = base.join("real");
+        let link = base.join("link");
+        std::fs::create_dir(&real).expect("project");
+        std::os::unix::fs::symlink(&real, &link).expect("symlink");
+        let real_key = real.to_str().expect("utf-8 tempdir").to_string();
+        let fs = init(
+            json!({ "agent_sandbox": { "enabled": true, "projects": {
+                real_key.clone(): { "enabled": false, "rw_maps": ["/w/cache"] }
+            } } }),
+            cx,
+        )
+        .await;
+        let cx = cx.add_empty_window();
+        let task = cx.update(|window, cx| set_project_enabled(&link, true, window, cx));
+        cx.run_until_parked();
+        assert!(task.await, "saved");
+        let projects = projects(&fs).await;
+        assert_eq!(
+            projects.keys().collect::<Vec<_>>(),
+            [&real_key],
+            "the resolved key, not the link"
+        );
+        assert_eq!(
+            projects[&real_key],
+            json!({ "enabled": true, "rw_maps": ["/w/cache"] })
+        );
+    }
+
+    #[test]
+    fn confirm_target_key_falls_back_to_the_root_as_spelled() {
+        let keys = ["/w/other".to_string()];
+        assert_eq!(
+            target_project_key(&keys, "/w/app", Some(Path::new("/w/gone"))),
+            "/w/app",
+            "a resolved key the file no longer holds is not written"
+        );
+        assert_eq!(
+            target_project_key(&keys, "/w/app", Some(Path::new("/w/other"))),
+            "/w/other"
+        );
+    }
+
+    #[cfg(unix)]
+    #[gpui::test]
+    async fn confirm_refuses_a_non_utf8_root_before_asking(cx: &mut TestAppContext) {
+        use std::ffi::OsStr;
+        use std::os::unix::ffi::OsStrExt as _;
+
+        let fs = init(json!({ "agent_sandbox": { "enabled": true } }), cx).await;
+        let before = file_text(&fs).await;
+        let root = Path::new(OsStr::from_bytes(b"/w/\xffapp"));
+        let log = LogCapture::start();
+        let cx = cx.add_empty_window();
+        for enabled in [false, true] {
+            let task = cx.update(|window, cx| set_project_enabled(root, enabled, window, cx));
+            cx.run_until_parked();
+            assert!(!cx.has_pending_prompt(), "refused before asking");
+            assert!(!task.await, "nothing saved");
+        }
+        assert_eq!(file_text(&fs).await, before, "no write");
+        let refused = log.matching("event=settings_write_refused");
+        assert_eq!(refused.len(), 2, "{refused:?}");
+        assert!(
+            refused
+                .iter()
+                .all(|(level, line)| *level == log::Level::Warn
+                    && line.contains("reason=non_utf8_root")
+                    && line.contains("root=\"/w/\u{FFFD}app\""))
+        );
+        assert_eq!(
+            non_utf8_root_message(root),
+            "The agent sandbox cannot be configured for a project whose path is not valid UTF-8: \
+             /w/\u{FFFD}app"
+        );
+    }
+
+    #[gpui::test]
+    async fn confirm_the_first_button_never_turns_anything_off(cx: &mut TestAppContext) {
+        let fs = init(
+            json!({ "agent_sandbox": { "enabled": true, "projects": { "/w/app": { "enabled": true } } } }),
+            cx,
+        )
+        .await;
+        let before = file_text(&fs).await;
+        let cx = cx.add_empty_window();
+        let prompts = prompts_by_index(cx);
+
+        let task =
+            cx.update(|window, cx| set_project_enabled(Path::new("/w/app"), false, window, cx));
+        cx.run_until_parked();
+        press(&prompts, 0, cx);
+        assert!(!task.await, "project: the first button cancels");
+
+        let task = cx.update(|window, cx| set_default_enabled(false, window, cx));
+        cx.run_until_parked();
+        press(&prompts, 0, cx);
+        assert!(!task.await, "default: the first button cancels");
+
+        let task = cx.update(|window, cx| confirm_forget("gotchas/fs.md", window, cx));
+        cx.run_until_parked();
+        press(&prompts, 0, cx);
+        assert!(!task.await, "forget: the first button cancels");
+
+        assert_eq!(file_text(&fs).await, before, "nothing written");
+        assert_eq!(TURN_OFF_BUTTONS, ["Cancel", "Turn Off"]);
+        assert_eq!(FORGET_BUTTONS, ["Cancel", "Forget"]);
+        assert_eq!(
+            prompts.borrow().buttons,
+            [
+                vec!["Cancel", "Turn Off"],
+                vec!["Cancel", "Turn Off"],
+                vec!["Cancel", "Forget"]
+            ],
+            "the buttons the user saw, in order"
+        );
+
+        let task =
+            cx.update(|window, cx| set_project_enabled(Path::new("/w/app"), false, window, cx));
+        cx.run_until_parked();
+        press(&prompts, 1, cx);
+        assert!(task.await, "the second button turns it off");
+        assert_eq!(projects(&fs).await["/w/app"], json!({ "enabled": false }));
+    }
+
+    #[gpui::test]
+    async fn confirm_write_failure_logs_one_key_value_line(cx: &mut TestAppContext) {
+        let fs = init(json!({ "agent_sandbox": { "enabled": false } }), cx).await;
+        // A directory where the settings file should be: loading it fails.
+        fs.remove_file(paths::settings_file(), Default::default())
+            .await
+            .expect("remove the file");
+        fs.create_dir(paths::settings_file())
+            .await
+            .expect("a directory in its place");
+        let log = LogCapture::start();
+        let cx = cx.add_empty_window();
+        let task = cx.update(|window, cx| set_default_enabled(true, window, cx));
+        cx.run_until_parked();
+        assert!(!task.await, "a failed write is not reported as saved");
+        let failed = log.matching("event=settings_write_failed");
+        assert_eq!(failed.len(), 1, "{failed:?}");
+        let (level, line) = &failed[0];
+        assert_eq!(*level, log::Level::Error);
+        assert!(!line.contains('\n'), "one line: {line}");
+        assert!(
+            line.starts_with(
+                "agent_sandbox event=settings_write_failed key=\"agent_sandbox.enabled\" error=\""
+            ) && line.ends_with('"'),
+            "{line}"
+        );
+    }
+
+    #[test]
+    fn confirm_failure_line_quotes_the_key_and_keeps_the_first_error_line() {
+        let error = anyhow::anyhow!("disk full\nsecond line").context("Failed to write settings");
+        assert_eq!(
+            write_failure_log_line("agent_sandbox.projects[/w/my app].enabled", &error),
+            "agent_sandbox event=settings_write_failed \
+             key=\"agent_sandbox.projects[/w/my app].enabled\" \
+             error=\"Failed to write settings: disk full\""
+        );
+    }
+}
diff --git a/crates/agent_sandbox/src/confirm_tests.rs b/crates/agent_sandbox/src/confirm_tests.rs
new file mode 100644
index 0000000..b0ec4f9
--- /dev/null
+++ b/crates/agent_sandbox/src/confirm_tests.rs
@@ -0,0 +1,236 @@
+// Story 043, task 5.1 — the turn-off and forget confirmations and the only two writers of the
+// switches (R4.1, R4.2, R4.3, R4.4, R6.4 text). Wired in the crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod confirm_tests;
+//
+// Validation filter: `confirm` (every test path is `confirm_tests::…`).
+//
+// Contract (design.md §Confirmation). Assumed signatures — adjust the `use`/call only:
+//   - `crate::confirm::OffScope::{Project(String), Default}` (the project's display name)
+//   - `crate::confirm::confirm_turn_off(scope: OffScope, window: &mut Window, cx: &mut App) -> Task<bool>`
+//   - `crate::confirm::confirm_forget(path: &str, window: &mut Window, cx: &mut App) -> Task<bool>`
+//   - `crate::confirm::set_project_enabled(root: &Path, enabled: bool, window, cx) -> Task<_>`
+//   - `crate::confirm::set_default_enabled(enabled: bool, window, cx) -> Task<_>`
+//   Both setters write the user settings file through `update_settings_file` and the global
+//   `Fs`; the tests read that file back from `paths::settings_file()` on a FakeFs.
+//
+// Hostile halves first:
+//   - off without consent, wrongly WRITE: Cancel leaves the file byte-identical and the switch on.
+//   - consent, wrongly COLLAPSE scope: Turn Off for project A writes only A — not the default and
+//     not project B.
+//   - asking, wrongly ASK: turning on never prompts (R4.4); wrongly SKIP: turning off always does,
+//     for the default as well as for a project.
+
+use std::path::Path;
+use std::sync::Arc;
+
+use fs::{FakeFs, Fs};
+use gpui::{TestAppContext, UpdateGlobal as _, VisualTestContext};
+use serde_json::{Value, json};
+use settings::{Settings as _, SettingsStore};
+
+use crate::AgentSandboxSettings;
+use crate::confirm::{
+    OffScope, confirm_forget, confirm_turn_off, set_default_enabled, set_project_enabled,
+};
+
+const DETAIL: &str = "The agent will run with your user's full access the next time it starts.";
+const ROOT: &str = "/w/app";
+const OTHER: &str = "/w/lib";
+
+async fn init(user: Value, cx: &mut TestAppContext) -> Arc<FakeFs> {
+    let fs = FakeFs::new(cx.executor());
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        <dyn Fs>::set_global(fs.clone(), cx);
+        crate::init(cx);
+    });
+    fs.create_dir(paths::config_dir())
+        .await
+        .expect("the config dir");
+    fs.insert_file(paths::settings_file(), user.to_string().into_bytes())
+        .await;
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&user.to_string(), cx)
+                .result()
+                .expect("user settings parse");
+        })
+    });
+    cx.run_until_parked();
+    fs
+}
+
+async fn file_text(fs: &Arc<FakeFs>) -> String {
+    fs.load(paths::settings_file())
+        .await
+        .expect("settings file")
+}
+
+async fn file_json(fs: &Arc<FakeFs>) -> Value {
+    serde_json_lenient::from_str(&file_text(fs).await).expect("settings JSON")
+}
+
+fn both_on() -> Value {
+    json!({ "agent_sandbox": { "enabled": true, "projects": {
+        ROOT: { "enabled": true }, OTHER: { "enabled": true }
+    } } })
+}
+
+fn window(cx: &mut TestAppContext) -> &mut VisualTestContext {
+    cx.add_empty_window()
+}
+
+// --- R4.3: Cancel writes nothing ------------------------------------------------------------------
+
+#[gpui::test]
+async fn confirm_cancel_leaves_the_file_and_the_switch(cx: &mut TestAppContext) {
+    let fs = init(both_on(), cx).await;
+    let before = file_text(&fs).await;
+    let cx = window(cx);
+    let task = cx.update(|window, cx| set_project_enabled(Path::new(ROOT), false, window, cx));
+    cx.run_until_parked();
+    assert!(cx.has_pending_prompt(), "turning off asks first");
+    cx.simulate_prompt_answer("Cancel");
+    let _ = task.await;
+    cx.run_until_parked();
+    assert_eq!(
+        file_text(&fs).await,
+        before,
+        "Cancel leaves the file byte-identical"
+    );
+    assert!(
+        cx.update(|_, cx| AgentSandboxSettings::get_global(cx).effective_enabled(Path::new(ROOT)))
+    );
+}
+
+#[gpui::test]
+async fn confirm_cancel_on_the_default_writes_nothing(cx: &mut TestAppContext) {
+    let fs = init(both_on(), cx).await;
+    let before = file_text(&fs).await;
+    let cx = window(cx);
+    let task = cx.update(|window, cx| set_default_enabled(false, window, cx));
+    cx.run_until_parked();
+    cx.simulate_prompt_answer("Cancel");
+    let _ = task.await;
+    cx.run_until_parked();
+    assert_eq!(file_text(&fs).await, before);
+}
+
+// --- R4.1/R4.2: the texts and Turn Off ------------------------------------------------------------
+
+#[gpui::test]
+async fn confirm_turn_off_writes_only_that_project(cx: &mut TestAppContext) {
+    let fs = init(both_on(), cx).await;
+    let cx = window(cx);
+    let task = cx.update(|window, cx| set_project_enabled(Path::new(ROOT), false, window, cx));
+    cx.run_until_parked();
+    let (message, detail) = cx.pending_prompt().expect("a prompt");
+    assert!(
+        message.starts_with("Turn off the agent sandbox for ") && message.ends_with('?'),
+        "{message}"
+    );
+    assert!(message.contains("app"), "names the project: {message}");
+    assert_eq!(detail, DETAIL);
+    cx.simulate_prompt_answer("Turn Off");
+    let _ = task.await;
+    cx.run_until_parked();
+    let written = file_json(&fs).await;
+    assert_eq!(
+        written["agent_sandbox"]["projects"][ROOT]["enabled"],
+        json!(false)
+    );
+    assert_eq!(
+        written["agent_sandbox"]["projects"][OTHER]["enabled"],
+        json!(true),
+        "project B untouched"
+    );
+    assert_eq!(
+        written["agent_sandbox"]["enabled"],
+        json!(true),
+        "the default untouched"
+    );
+}
+
+#[gpui::test]
+async fn confirm_default_off_names_projects_without_their_own_setting(cx: &mut TestAppContext) {
+    let fs = init(both_on(), cx).await;
+    let cx = window(cx);
+    let task = cx.update(|window, cx| set_default_enabled(false, window, cx));
+    cx.run_until_parked();
+    let (message, detail) = cx.pending_prompt().expect("a prompt");
+    assert_eq!(
+        message,
+        "Turn off the agent sandbox for projects without their own setting?"
+    );
+    assert_eq!(detail, DETAIL);
+    cx.simulate_prompt_answer("Turn Off");
+    let _ = task.await;
+    cx.run_until_parked();
+    let written = file_json(&fs).await;
+    assert_eq!(written["agent_sandbox"]["enabled"], json!(false));
+    assert_eq!(
+        written["agent_sandbox"]["projects"][ROOT]["enabled"],
+        json!(true)
+    );
+}
+
+// --- R4.4: on never asks ----------------------------------------------------------------------------
+
+#[gpui::test]
+async fn confirm_turning_on_saves_without_asking(cx: &mut TestAppContext) {
+    let fs = init(json!({ "agent_sandbox": { "enabled": false } }), cx).await;
+    let cx = window(cx);
+    let task = cx.update(|window, cx| set_project_enabled(Path::new(ROOT), true, window, cx));
+    cx.run_until_parked();
+    assert!(!cx.has_pending_prompt(), "turning on never asks");
+    let _ = task.await;
+    cx.run_until_parked();
+    assert_eq!(
+        file_json(&fs).await["agent_sandbox"]["projects"][ROOT]["enabled"],
+        json!(true)
+    );
+
+    let task = cx.update(|window, cx| set_default_enabled(true, window, cx));
+    cx.run_until_parked();
+    assert!(!cx.has_pending_prompt());
+    let _ = task.await;
+    cx.run_until_parked();
+    assert_eq!(
+        file_json(&fs).await["agent_sandbox"]["enabled"],
+        json!(true)
+    );
+}
+
+// --- the prompt helpers themselves ----------------------------------------------------------------
+
+#[gpui::test]
+async fn confirm_turn_off_is_true_only_for_turn_off(cx: &mut TestAppContext) {
+    init(both_on(), cx).await;
+    let cx = window(cx);
+    for (button, expected) in [("Cancel", false), ("Turn Off", true)] {
+        let task =
+            cx.update(|window, cx| confirm_turn_off(OffScope::Project("app".into()), window, cx));
+        cx.run_until_parked();
+        cx.simulate_prompt_answer(button);
+        assert_eq!(task.await, expected, "{button}");
+    }
+}
+
+#[gpui::test]
+async fn confirm_forget_text_and_answers(cx: &mut TestAppContext) {
+    init(both_on(), cx).await;
+    let cx = window(cx);
+    for (button, expected) in [("Cancel", false), ("Forget", true)] {
+        let task = cx.update(|window, cx| confirm_forget("gotchas/fs.md", window, cx));
+        cx.run_until_parked();
+        let (message, detail) = cx.pending_prompt().expect("a prompt");
+        assert_eq!(message, "Forget gotchas/fs.md?");
+        assert_eq!(detail, "ai-memory deletes the page. This cannot be undone.");
+        cx.simulate_prompt_answer(button);
+        assert_eq!(task.await, expected, "{button}");
+    }
+}
diff --git a/crates/agent_sandbox/src/launch.rs b/crates/agent_sandbox/src/launch.rs
new file mode 100644
index 0000000..911139f
--- /dev/null
+++ b/crates/agent_sandbox/src/launch.rs
@@ -0,0 +1,1104 @@
+//! The launch decision for an external agent: jailed through ai-jail, started unchanged, or
+//! refused (R1, R2.6–R2.10, R7.4), and the registry of what each running agent was started with,
+//! read by the toolbar icon and the settings page.
+//!
+//! A refusal never falls back to an unjailed start: while the sandbox is on for a project, every
+//! Claude adapter of it starts through ai-jail or does not start (Q4).
+
+use std::collections::HashSet;
+use std::ffi::OsStr;
+use std::fmt;
+use std::hash::{DefaultHasher, Hash as _, Hasher as _};
+use std::io;
+use std::path::{Path, PathBuf};
+
+use gpui::{App, AsyncApp, BackgroundExecutor, Entity, Global};
+use project::Project;
+use project::agent_registry_store::{ZEO_PLUS_AGENT_ID, ZEO_TUI_AGENT_ID};
+use project::agent_server_store::{AgentId, AgentServerCommand};
+use semver::Version;
+use settings::Settings as _;
+
+use crate::command::{
+    JailInputs, NO_PROXY_CARRIER, generate, keep_loopback_off_proxy, loopback_no_proxy,
+};
+use crate::probe::{self, ProbeError};
+use crate::settings::{
+    AgentMemorySettings, AgentSandboxSettings, ProjectPolicy, lexical_match, resolve_project_key,
+};
+use crate::validate::{
+    self, ForbiddenRule, HostError, LimitError, ProtectedSet, RwMapError, expand_tilde,
+};
+
+/// The sentence every refusal ends with (R1.9).
+pub const FAIL_CLOSED: &str = "The agent was not started without the sandbox.";
+
+/// The R3.10 / R5.9 sentence: memory is on but this ai-jail cannot forward its port.
+pub const MEMORY_PORT_UNSUPPORTED: &str =
+    "ai-memory is not reachable from inside the jail: this ai-jail has no --forward-port";
+
+/// The binary and package names of the Claude adapters (R1.13). `claude-agent-tui` is the TUI
+/// package's npm bin; `claude-agent-acp-tui` is the system adapter's name.
+const ADAPTER_NAMES: [&str; 3] = [
+    "claude-agent-acp-plus",
+    "claude-agent-acp-tui",
+    "claude-agent-tui",
+];
+const ADAPTER_PACKAGES: [&str; 2] = [
+    "@lucascouts/claude-agent-acp-plus",
+    "@lucascouts/claude-agent-tui",
+];
+
+/// Why an agent started as it did, logged as `reason=` (R9.1).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Reason {
+    On,
+    Off,
+    OwnCommand,
+    Remote,
+    NotClaude,
+}
+
+impl Reason {
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::On => "on",
+            Self::Off => "off",
+            Self::OwnCommand => "own_command",
+            Self::Remote => "remote",
+            Self::NotClaude => "not_claude",
+        }
+    }
+}
+
+/// What the icon (R5.9) and the page (R3.10) say about ai-memory inside the jail.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MemoryPort {
+    Forwarded(u16),
+    /// Memory on and loopback, but this ai-jail has no `--forward-port` (R7.4).
+    Unsupported,
+    Off,
+    NonLoopback,
+}
+
+/// What one agent was started with.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct LaunchRecord {
+    pub agent_id: AgentId,
+    /// The checked root; a jailed start's working directory.
+    pub project_root: PathBuf,
+    pub reason: Reason,
+    pub ai_jail_version: Option<Version>,
+    pub ai_jail_path: Option<PathBuf>,
+    /// The Claude config directory first, then every other writable folder, canonical.
+    pub rw_maps: Vec<PathBuf>,
+    pub allow_hosts: Vec<String>,
+    pub forward_ports: Vec<u16>,
+    pub memory_port: MemoryPort,
+    /// [`drifted`] compares it with the settings of now.
+    pub settings_fingerprint: u64,
+    /// [`memory_drifted`] compares it with the memory settings of now.
+    pub memory_fingerprint: u64,
+    /// The `agent_sandbox.projects` key the start used, if any.
+    pub settings_key: Option<String>,
+}
+
+pub enum LaunchDecision {
+    Jailed(LaunchRecord, AgentServerCommand),
+    Unchanged(LaunchRecord),
+    Refused(LaunchError),
+}
+
+/// How a jailed start's process is spawned, besides its command.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct JailSpawn {
+    /// The resolved root `check_root` approved: ai-jail binds its working directory as the
+    /// writable project, so it must be the checked path, not a symlink that could be re-pointed.
+    pub cwd: PathBuf,
+    /// ai-jail's whole environment, sorted by name — the spawner clears everything else. Only the
+    /// configured names (with their values) and a base taken from Zeo's own process: ai-jail
+    /// copies part of its environment into the jail (proxies, `LC_*`, `XDG_*`, …) and reads
+    /// `HOME` and its own variables to find its configuration, so a login-shell environment
+    /// would leak into the jail and steer ai-jail.
+    pub env: Vec<(String, String)>,
+}
+
+/// A launch decision and, for a jailed start, how to spawn it.
+pub struct LaunchPlan {
+    pub decision: LaunchDecision,
+    /// `Some` exactly when `decision` is `Jailed`.
+    pub jail_spawn: Option<JailSpawn>,
+}
+
+impl LaunchPlan {
+    fn without_spawn(decision: LaunchDecision) -> Self {
+        Self {
+            decision,
+            jail_spawn: None,
+        }
+    }
+}
+
+/// Why a project root cannot be handed to ai-jail.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RootProblem {
+    /// ai-jail reads `--map` as `PATH|SOURCE:DEST`: `<root>/.git` would bind nothing.
+    Colon,
+    /// The path would reach argv through a lossy conversion as a path that does not exist.
+    NotUtf8,
+}
+
+/// Why an agent was refused. Each `Display` is one or more full sentences.
+#[derive(Debug)]
+pub enum LaunchCause {
+    NoRoot,
+    RootUnusable {
+        root: PathBuf,
+        problem: RootProblem,
+    },
+    RootForbidden {
+        root: PathBuf,
+        rule: ForbiddenRule,
+    },
+    RootMissing {
+        root: PathBuf,
+    },
+    NotInstalled {
+        /// The configured `ai_jail_path`, `~` expanded; `None` when looked up on `PATH`.
+        configured: Option<PathBuf>,
+    },
+    VersionUnreadable(ProbeError),
+    ClaudeDirInvalid(RwMapError),
+    ClaudeDirMissing {
+        dir: PathBuf,
+    },
+    InvalidMap(RwMapError),
+    InvalidHost(HostError),
+    Limit(LimitError),
+    NestedMap {
+        inner: PathBuf,
+        outer: PathBuf,
+    },
+    InvalidEnvKey {
+        /// The key up to its first `=`, so a `NAME=VALUE` key never shows its value.
+        shown: String,
+    },
+    SymlinkedMount {
+        path: PathBuf,
+    },
+    ZedDirCreate {
+        dir: PathBuf,
+        error: io::Error,
+    },
+    InvalidMemoryEndpoint(InvalidMemoryEndpoint),
+}
+
+impl LaunchCause {
+    /// The `cause=` token of the refusal's log line (R9.2).
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            Self::NoRoot => "no_root",
+            Self::RootUnusable { .. } => "root_unusable",
+            Self::RootForbidden { .. } => "root_forbidden",
+            Self::RootMissing { .. } => "root_missing",
+            Self::NotInstalled { .. } => "not_installed",
+            Self::VersionUnreadable(_) => "version_unreadable",
+            Self::ClaudeDirInvalid(_) => "claude_dir_invalid",
+            Self::ClaudeDirMissing { .. } => "claude_dir_missing",
+            Self::InvalidMap(_) => "invalid_map",
+            Self::InvalidHost(_) => "invalid_host",
+            Self::Limit(_) => "limit",
+            Self::NestedMap { .. } => "nested_map",
+            Self::InvalidEnvKey { .. } => "invalid_env_key",
+            Self::SymlinkedMount { .. } => "symlinked_mount",
+            Self::ZedDirCreate { .. } => "zed_dir_create",
+            Self::InvalidMemoryEndpoint(_) => "invalid_memory_endpoint",
+        }
+    }
+}
+
+impl fmt::Display for LaunchCause {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::NoRoot => write!(
+                f,
+                "The project has no local folder to run the agent in, and the agent sandbox is on."
+            ),
+            Self::RootUnusable {
+                root,
+                problem: RootProblem::Colon,
+            } => write!(
+                f,
+                "The project folder {} contains ':', which ai-jail reads as SOURCE:DEST, so its .git and .zed could not be kept read-only.",
+                root.display()
+            ),
+            Self::RootUnusable {
+                root,
+                problem: RootProblem::NotUtf8,
+            } => write!(
+                f,
+                "The project folder {} is not valid UTF-8, so ai-jail cannot be given its .git and .zed.",
+                root.display()
+            ),
+            Self::RootForbidden { root, rule } => write!(
+                f,
+                "The project folder {} {rule}; the agent sandbox does not run an agent there.",
+                root.display()
+            ),
+            Self::RootMissing { root } => write!(
+                f,
+                "The project folder {} no longer exists or is not a folder.",
+                root.display()
+            ),
+            Self::NotInstalled {
+                configured: Some(path),
+            } => write!(
+                f,
+                "ai-jail was not found at {} (agent_sandbox.ai_jail_path).",
+                path.display()
+            ),
+            Self::NotInstalled { configured: None } => write!(
+                f,
+                "ai-jail was not found on PATH; install it or set agent_sandbox.ai_jail_path."
+            ),
+            Self::VersionUnreadable(error) => {
+                write!(f, "The version of ai-jail could not be read: {error}.")
+            }
+            Self::ClaudeDirInvalid(error) => write!(
+                f,
+                "The Claude config directory (agent_sandbox.claude_config_dir) cannot be mapped: {error}"
+            ),
+            Self::ClaudeDirMissing { dir } => write!(
+                f,
+                "The Claude config directory {dir} does not exist. Create it and log in with `CLAUDE_CONFIG_DIR={dir} claude`.",
+                dir = dir.display()
+            ),
+            Self::InvalidMap(error) => write!(f, "{error}"),
+            Self::InvalidHost(error) => write!(f, "{error}"),
+            Self::Limit(error) => write!(f, "{error}"),
+            Self::NestedMap { inner, outer } => write!(
+                f,
+                "Writable folder {} lies inside {}, which the agent can write; it could swap the inner folder for a symlink before the jail starts. Keep the outer folder only.",
+                inner.display(),
+                outer.display()
+            ),
+            Self::InvalidEnvKey { shown } => write!(
+                f,
+                "The environment variable name \"{shown}\" cannot be passed into the jail: a name has only letters, digits and '_', and does not start with a digit."
+            ),
+            Self::SymlinkedMount { path } => write!(
+                f,
+                "{} is a symlink; ai-jail cannot keep it read-only. Replace it with the folder itself.",
+                path.display()
+            ),
+            Self::ZedDirCreate { dir, error } => {
+                write!(f, "Could not create {}: {error}.", dir.display())
+            }
+            Self::InvalidMemoryEndpoint(error) => write!(f, "{error}"),
+        }
+    }
+}
+
+/// A refused start: the root, the cause, and the R9.1 counts for the R9.2 log line.
+#[derive(Debug)]
+pub struct LaunchError {
+    pub agent_id: AgentId,
+    pub project_root: Option<PathBuf>,
+    pub cause: LaunchCause,
+    pub ai_jail_version: Option<Version>,
+    pub rw_maps: usize,
+    pub allow_hosts: usize,
+    pub forward_ports: usize,
+}
+
+impl fmt::Display for LaunchError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match &self.project_root {
+            Some(root) => write!(
+                f,
+                "Agent sandbox, project {}: {} {FAIL_CLOSED}",
+                root.display(),
+                self.cause
+            ),
+            None => write!(f, "Agent sandbox: {} {FAIL_CLOSED}", self.cause),
+        }
+    }
+}
+
+impl std::error::Error for LaunchError {}
+
+/// An `agent_memory.endpoint` that is not a URL, shown without userinfo, query or fragment.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct InvalidMemoryEndpoint {
+    pub shown: String,
+}
+
+impl fmt::Display for InvalidMemoryEndpoint {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "The ai-memory endpoint \"{}\" (agent_memory.endpoint) is not a URL.",
+            self.shown
+        )
+    }
+}
+
+impl std::error::Error for InvalidMemoryEndpoint {}
+
+/// Where the protected paths are. Tests substitute a home of their own.
+pub(crate) struct LaunchEnv {
+    pub home: PathBuf,
+    pub config_dir: PathBuf,
+    pub runtime_dir: Option<PathBuf>,
+}
+
+impl LaunchEnv {
+    fn from_process() -> Self {
+        Self {
+            home: paths::home_dir().clone(),
+            config_dir: paths::config_dir().clone(),
+            runtime_dir: std::env::var_os("XDG_RUNTIME_DIR")
+                .map(PathBuf::from)
+                .filter(|dir| dir.is_absolute()),
+        }
+    }
+}
+
+/// The folder an agent of `project` is started in: the expression `spawn_stdio` uses, except
+/// that a project without a visible worktree has none (`default_path_list` would give home).
+pub fn project_root(project: &Entity<Project>, cx: &App) -> Option<PathBuf> {
+    let project = project.read(cx);
+    project.visible_worktrees(cx).next()?;
+    project
+        .default_path_list(cx)
+        .ordered_paths()
+        .next()
+        .cloned()
+}
+
+/// Decides how the agent `agent_id` of `project` starts. `configured_env` holds the names of the
+/// variables the agent's own settings configure: only those are forwarded into the jail.
+///
+/// Order: remote → own command → not a Claude adapter → no root → off → checks → jailed.
+pub async fn decide(
+    agent_id: AgentId,
+    project: &Entity<Project>,
+    cmd: AgentServerCommand,
+    configured_env: &HashSet<String>,
+    cx: &mut AsyncApp,
+) -> LaunchDecision {
+    decide_in(
+        agent_id,
+        project,
+        cmd,
+        configured_env,
+        LaunchEnv::from_process(),
+        cx,
+    )
+    .await
+}
+
+/// [`decide`], plus how a jailed start must be spawned. A spawner of a `Jailed` decision must use
+/// [`JailSpawn::cwd`] and only [`JailSpawn::env`].
+pub async fn plan(
+    agent_id: AgentId,
+    project: &Entity<Project>,
+    cmd: AgentServerCommand,
+    configured_env: &HashSet<String>,
+    cx: &mut AsyncApp,
+) -> LaunchPlan {
+    plan_in(
+        agent_id,
+        project,
+        cmd,
+        configured_env,
+        LaunchEnv::from_process(),
+        cx,
+    )
+    .await
+}
+
+pub(crate) async fn decide_in(
+    agent_id: AgentId,
+    project: &Entity<Project>,
+    cmd: AgentServerCommand,
+    configured_env: &HashSet<String>,
+    env: LaunchEnv,
+    cx: &mut AsyncApp,
+) -> LaunchDecision {
+    plan_in(agent_id, project, cmd, configured_env, env, cx)
+        .await
+        .decision
+}
+
+pub(crate) async fn plan_in(
+    agent_id: AgentId,
+    project: &Entity<Project>,
+    cmd: AgentServerCommand,
+    configured_env: &HashSet<String>,
+    env: LaunchEnv,
+    cx: &mut AsyncApp,
+) -> LaunchPlan {
+    let (is_local, root, sandbox, memory) = cx.update(|cx| {
+        (
+            project.read(cx).is_local(),
+            project_root(project, cx),
+            AgentSandboxSettings::get_global(cx).clone(),
+            AgentMemorySettings::get_global(cx).clone(),
+        )
+    });
+    let local = LocalLaunch {
+        agent_id,
+        root,
+        cmd,
+        configured_env: configured_env.clone(),
+        sandbox,
+        memory,
+        env,
+        executor: cx.background_executor().clone(),
+    };
+    if !is_local {
+        return LaunchPlan::without_spawn(LaunchDecision::Unchanged(
+            local.unmanaged_record(Reason::Remote),
+        ));
+    }
+    if local.cmd.path.file_name() == Some(OsStr::new("ai-jail")) {
+        return LaunchPlan::without_spawn(LaunchDecision::Unchanged(
+            local.unmanaged_record(Reason::OwnCommand),
+        ));
+    }
+    cx.background_executor().spawn(local.decide()).await
+}
+
+/// Everything a local decision needs, moved to the background executor: every blocking file
+/// system call of a launch happens there.
+struct LocalLaunch {
+    agent_id: AgentId,
+    root: Option<PathBuf>,
+    cmd: AgentServerCommand,
+    configured_env: HashSet<String>,
+    sandbox: AgentSandboxSettings,
+    memory: AgentMemorySettings,
+    env: LaunchEnv,
+    executor: BackgroundExecutor,
+}
+
+/// What the checks passed on to the generated command.
+struct Checked {
+    /// The resolved root `check_root` approved; the jail's cwd and the base of its maps.
+    root: PathBuf,
+    ai_jail: PathBuf,
+    version: Version,
+    claude_dir: PathBuf,
+    rw_maps: Vec<PathBuf>,
+    allow_hosts: Vec<String>,
+    forward_ports: Vec<u16>,
+    memory_port: MemoryPort,
+    env_names: Vec<String>,
+}
+
+impl LocalLaunch {
+    async fn decide(self) -> LaunchPlan {
+        if !is_claude_adapter(&self.agent_id, &self.cmd) && !resolves_to_adapter(&self.cmd.path) {
+            return LaunchPlan::without_spawn(LaunchDecision::Unchanged(
+                self.unmanaged_record(Reason::NotClaude),
+            ));
+        }
+        let Some(root) = self.root.clone() else {
+            let policy = self.sandbox.policy_for_key(None);
+            if policy.enabled {
+                return self.refuse(None, &policy, LaunchCause::NoRoot, None);
+            }
+            return LaunchPlan::without_spawn(LaunchDecision::Unchanged(self.record(
+                PathBuf::new(),
+                Reason::Off,
+                &policy,
+                None,
+            )));
+        };
+        let key = resolve_project_key(self.sandbox.project_keys(), &root).map(str::to_string);
+        let policy = self.sandbox.policy_for_key(key.as_deref());
+        if !policy.enabled {
+            return LaunchPlan::without_spawn(LaunchDecision::Unchanged(self.record(
+                root,
+                Reason::Off,
+                &policy,
+                key,
+            )));
+        }
+        let checked = match self.check(&root, &policy).await {
+            Ok(checked) => checked,
+            Err(refusal) => {
+                let (cause, version) = *refusal;
+                return self.refuse(Some(root), &policy, cause, version);
+            }
+        };
+        let forwarded = matches!(checked.memory_port, MemoryPort::Forwarded(_));
+        let jail_spawn = JailSpawn {
+            cwd: checked.root.clone(),
+            env: self.jail_env(&checked.env_names, forwarded),
+        };
+        // The record keeps the root as the project spells it: the registry and the UI look it
+        // up by that spelling.
+        let mut record = self.record(root, Reason::On, &policy, key);
+        let inputs = JailInputs {
+            ai_jail: &checked.ai_jail,
+            project_root: &checked.root,
+            claude_dir: &checked.claude_dir,
+            rw_maps: &checked.rw_maps,
+            allow_hosts: &checked.allow_hosts,
+            forward_ports: &checked.forward_ports,
+            env_names: checked.env_names.clone(),
+        };
+        let mut jailed = generate(self.cmd, &inputs);
+        if forwarded {
+            // ai-jail's filtered egress would send loopback HTTP to its proxy (R7.1).
+            jailed = keep_loopback_off_proxy(jailed);
+        }
+        record.ai_jail_version = Some(checked.version);
+        record.rw_maps = std::iter::once(checked.claude_dir)
+            .chain(checked.rw_maps)
+            .collect();
+        record.allow_hosts = checked.allow_hosts;
+        record.forward_ports = checked.forward_ports;
+        record.memory_port = checked.memory_port;
+        record.ai_jail_path = Some(checked.ai_jail);
+        LaunchPlan {
+            decision: LaunchDecision::Jailed(record, jailed),
+            jail_spawn: Some(jail_spawn),
+        }
+    }
+
+    /// ai-jail's whole environment: Zeo's own `PATH` and `LANG`, `XDG_RUNTIME_DIR`, the
+    /// forwarded names with their values from the command, then `HOME`. A configured name
+    /// overrides the base (an npm adapter's `PATH`), except `HOME`: ai-jail finds its global
+    /// configuration through it, so it is always Zeo's. With a forwarded memory port it also
+    /// carries the loopback proxy exclusion, merged with the forwarded `no_proxy`/`NO_PROXY`.
+    fn jail_env(&self, forwarded: &[String], memory_forwarded: bool) -> Vec<(String, String)> {
+        let mut env = std::collections::BTreeMap::new();
+        for name in ["PATH", "LANG"] {
+            if let Ok(value) = std::env::var(name) {
+                env.insert(name.to_string(), value);
+            }
+        }
+        if let Some(runtime_dir) = self.env.runtime_dir.as_ref().and_then(|dir| dir.to_str()) {
+            env.insert("XDG_RUNTIME_DIR".to_string(), runtime_dir.to_string());
+        }
+        if let Some(command_env) = &self.cmd.env {
+            for name in forwarded {
+                if let Some(value) = command_env.get(name) {
+                    env.insert(name.clone(), value.clone());
+                }
+            }
+        }
+        if memory_forwarded {
+            let configured = ["no_proxy", "NO_PROXY"]
+                .into_iter()
+                .filter_map(|name| env.get(name).map(String::as_str));
+            let exclusion = loopback_no_proxy(configured);
+            env.insert(NO_PROXY_CARRIER.to_string(), exclusion);
+        }
+        env.insert(
+            "HOME".to_string(),
+            self.env.home.to_string_lossy().into_owned(),
+        );
+        env.into_iter().collect()
+    }
+
+    /// The checks of a jailed start, in order; the first failure refuses it.
+    async fn check(
+        &self,
+        root: &Path,
+        policy: &ProjectPolicy,
+    ) -> Result<Checked, Box<(LaunchCause, Option<Version>)>> {
+        let no_version = |cause| Box::new((cause, None));
+        // One scan of the protected folders per launch: every check below shares it.
+        let set = ProtectedSet::new(
+            &self.env.home,
+            &self.env.config_dir,
+            self.env.runtime_dir.as_deref(),
+        );
+        let canonical_root = check_root(root, &set).map_err(no_version)?;
+
+        let configured = self.sandbox.ai_jail_path.as_deref();
+        let ai_jail = match probe::locate(configured) {
+            Some(path) if path.is_file() => path,
+            located => {
+                return Err(no_version(LaunchCause::NotInstalled {
+                    configured: located.filter(|_| configured.is_some()),
+                }));
+            }
+        };
+        let binary = probe::probe(&ai_jail, &self.executor)
+            .await
+            .map_err(|error| no_version(LaunchCause::VersionUnreadable(error)))?;
+        let refuse = |cause| Box::new((cause, Some(binary.version.clone())));
+
+        let claude_raw = self.sandbox.claude_config_dir.as_str();
+        let claude_dir = match validate::validate_rw_map_with(claude_raw, &set) {
+            Ok(dir) => dir,
+            Err(RwMapError::Missing { .. }) => {
+                return Err(refuse(LaunchCause::ClaudeDirMissing {
+                    dir: expand_tilde(claude_raw, &self.env.home),
+                }));
+            }
+            Err(error) => return Err(refuse(LaunchCause::ClaudeDirInvalid(error))),
+        };
+
+        let mut rw_maps: Vec<PathBuf> = Vec::with_capacity(policy.rw_maps.len());
+        for raw in &policy.rw_maps {
+            let map = validate::validate_rw_map_with(raw, &set)
+                .map_err(|error| refuse(LaunchCause::InvalidMap(error)))?;
+            if map != claude_dir && !rw_maps.contains(&map) {
+                rw_maps.push(map);
+            }
+        }
+        let mut allow_hosts = Vec::with_capacity(policy.allow_hosts.len());
+        for raw in &policy.allow_hosts {
+            let host = validate::validate_host(raw)
+                .map_err(|error| refuse(LaunchCause::InvalidHost(error)))?;
+            allow_hosts.push(host);
+        }
+        validate::check_limits(rw_maps.len(), allow_hosts.len())
+            .map_err(|error| refuse(LaunchCause::Limit(error)))?;
+        check_nesting(&canonical_root, &claude_dir, &rw_maps).map_err(refuse)?;
+
+        let env_names = self.forwarded_env_names().map_err(refuse)?;
+
+        let memory_port = memory_port(&self.memory, binary.supports_forward_port)
+            .map_err(|error| refuse(LaunchCause::InvalidMemoryEndpoint(error)))?;
+        let forward_ports = match memory_port {
+            MemoryPort::Forwarded(port) => vec![port],
+            MemoryPort::Unsupported | MemoryPort::Off | MemoryPort::NonLoopback => Vec::new(),
+        };
+
+        for mount in [".git", ".zed"] {
+            let path = canonical_root.join(mount);
+            if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_symlink()) {
+                return Err(refuse(LaunchCause::SymlinkedMount { path }));
+            }
+        }
+        // Not recursive: a root deleted since the check above is never recreated.
+        let dot_zed = canonical_root.join(".zed");
+        match std::fs::create_dir(&dot_zed) {
+            Ok(()) => {}
+            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
+                if !dot_zed.is_dir() {
+                    return Err(refuse(LaunchCause::ZedDirCreate {
+                        dir: dot_zed,
+                        error: io::Error::new(
+                            io::ErrorKind::AlreadyExists,
+                            "it exists and is not a folder",
+                        ),
+                    }));
+                }
+            }
+            Err(error) => {
+                return Err(refuse(LaunchCause::ZedDirCreate {
+                    dir: dot_zed,
+                    error,
+                }));
+            }
+        }
+
+        Ok(Checked {
+            root: canonical_root,
+            ai_jail,
+            version: binary.version,
+            claude_dir,
+            rw_maps,
+            allow_hosts,
+            forward_ports,
+            memory_port,
+            env_names,
+        })
+    }
+
+    /// The configured names present in the command's env, sorted. A shell variable nobody
+    /// configured is neither forwarded nor validated.
+    fn forwarded_env_names(&self) -> Result<Vec<String>, LaunchCause> {
+        let Some(env) = &self.cmd.env else {
+            return Ok(Vec::new());
+        };
+        let mut names: Vec<String> = self
+            .configured_env
+            .iter()
+            .filter(|name| env.contains_key(*name))
+            .cloned()
+            .collect();
+        names.sort();
+        if let Some(bad) = names.iter().find(|name| !is_valid_env_key(name)) {
+            let shown = match bad.split_once('=') {
+                Some((name, _)) => format!("{name}=…"),
+                None => bad.clone(),
+            };
+            return Err(LaunchCause::InvalidEnvKey { shown });
+        }
+        Ok(names)
+    }
+
+    fn record(
+        &self,
+        root: PathBuf,
+        reason: Reason,
+        policy: &ProjectPolicy,
+        settings_key: Option<String>,
+    ) -> LaunchRecord {
+        LaunchRecord {
+            agent_id: self.agent_id.clone(),
+            project_root: root,
+            reason,
+            ai_jail_version: None,
+            ai_jail_path: None,
+            rw_maps: Vec::new(),
+            allow_hosts: Vec::new(),
+            forward_ports: Vec::new(),
+            memory_port: MemoryPort::Off,
+            settings_fingerprint: settings_fingerprint(&self.sandbox, policy),
+            memory_fingerprint: memory_fingerprint(&self.memory),
+            settings_key,
+        }
+    }
+
+    fn unmanaged_record(&self, reason: Reason) -> LaunchRecord {
+        LaunchRecord {
+            settings_fingerprint: 0,
+            memory_fingerprint: 0,
+            ..self.record(
+                self.root.clone().unwrap_or_default(),
+                reason,
+                &ProjectPolicy::default(),
+                None,
+            )
+        }
+    }
+
+    fn refuse(
+        &self,
+        root: Option<PathBuf>,
+        policy: &ProjectPolicy,
+        cause: LaunchCause,
+        ai_jail_version: Option<Version>,
+    ) -> LaunchPlan {
+        LaunchPlan::without_spawn(LaunchDecision::Refused(LaunchError {
+            agent_id: self.agent_id.clone(),
+            project_root: root,
+            cause,
+            ai_jail_version,
+            // The Claude config directory is always mapped.
+            rw_maps: policy.rw_maps.len() + 1,
+            allow_hosts: policy.allow_hosts.len(),
+            forward_ports: 0,
+        }))
+    }
+}
+
+/// Refuses a root ai-jail cannot keep `.git`/`.zed` read-only in (`:` or not UTF-8), a root that
+/// is not an existing folder, and one that breaks R2.8. Returns the canonical root.
+pub(crate) fn check_root(root: &Path, set: &ProtectedSet) -> Result<PathBuf, LaunchCause> {
+    let unusable = |problem| LaunchCause::RootUnusable {
+        root: root.to_path_buf(),
+        problem,
+    };
+    root_problem(root).map_or(Ok(()), |problem| Err(unusable(problem)))?;
+    if !std::fs::metadata(root).is_ok_and(|metadata| metadata.is_dir()) {
+        return Err(LaunchCause::RootMissing {
+            root: root.to_path_buf(),
+        });
+    }
+    let canonical = root.canonicalize().map_err(|_| LaunchCause::RootMissing {
+        root: root.to_path_buf(),
+    })?;
+    root_problem(&canonical).map_or(Ok(()), |problem| Err(unusable(problem)))?;
+    if let Some(rule) = set.rule_for(&canonical) {
+        return Err(LaunchCause::RootForbidden {
+            root: root.to_path_buf(),
+            rule,
+        });
+    }
+    Ok(canonical)
+}
+
+fn root_problem(path: &Path) -> Option<RootProblem> {
+    match path.to_str() {
+        None => Some(RootProblem::NotUtf8),
+        Some(text) if text.contains(':') => Some(RootProblem::Colon),
+        Some(_) => None,
+    }
+}
+
+/// Refuses a writable folder strictly inside the root, the Claude config directory or another
+/// folder, and the Claude config directory inside the root or a folder: the agent can write the
+/// outer one, so it could swap the inner one for a symlink before ai-jail binds it. All paths are
+/// canonical.
+pub fn check_nesting(
+    root: &Path,
+    claude_dir: &Path,
+    rw_maps: &[PathBuf],
+) -> Result<(), LaunchCause> {
+    let nested = |inner: &Path, outer: &Path| LaunchCause::NestedMap {
+        inner: inner.to_path_buf(),
+        outer: outer.to_path_buf(),
+    };
+    let strictly_inside = |inner: &Path, outer: &Path| inner != outer && inner.starts_with(outer);
+    if claude_dir.starts_with(root) {
+        return Err(nested(claude_dir, root));
+    }
+    if let Some(outer) = rw_maps.iter().find(|map| strictly_inside(claude_dir, map)) {
+        return Err(nested(claude_dir, outer));
+    }
+    for map in rw_maps {
+        for outer in [root, claude_dir]
+            .into_iter()
+            .chain(rw_maps.iter().map(PathBuf::as_path))
+        {
+            if strictly_inside(map, outer) {
+                return Err(nested(map, outer));
+            }
+        }
+    }
+    Ok(())
+}
+
+/// ai-jail's own rule for an `--env` name: `^[A-Za-z_][A-Za-z0-9_]*$`.
+fn is_valid_env_key(name: &str) -> bool {
+    let mut chars = name.chars();
+    chars
+        .next()
+        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
+        && chars.all(|next| next.is_ascii_alphanumeric() || next == '_')
+}
+
+/// Whether the agent is one of the Claude adapters by its registry id, its command's name, or an
+/// argument naming the adapter's file or npm package (R1.13). Reads no file system.
+fn is_claude_adapter(agent_id: &AgentId, cmd: &AgentServerCommand) -> bool {
+    let id: &str = agent_id.0.as_ref();
+    id == ZEO_PLUS_AGENT_ID
+        || id == ZEO_TUI_AGENT_ID
+        || names_adapter(&cmd.path)
+        || cmd.args.iter().any(|arg| {
+            arg_names_adapter(arg) || shell_words(arg).any(|word| arg_names_adapter(&word))
+        })
+}
+
+/// The words of `arg` read as a shell script (`bash -lc "npx -y <package>"`), each also split at
+/// the shell's control operators so `exec adapter;` yields `adapter`. An unbalanced quote falls
+/// back to splitting at whitespace.
+fn shell_words(arg: &str) -> impl Iterator<Item = String> + '_ {
+    let words =
+        shlex::split(arg).unwrap_or_else(|| arg.split_whitespace().map(str::to_string).collect());
+    words.into_iter().flat_map(|word| {
+        word.split([';', '&', '|', '(', ')', '`', '<', '>'])
+            .filter(|part| !part.is_empty())
+            .map(str::to_string)
+            .collect::<Vec<_>>()
+    })
+}
+
+/// The command resolved through `PATH` (a bare name) and symlinks names an adapter. Blocking.
+fn resolves_to_adapter(path: &Path) -> bool {
+    let bare = path.components().count() == 1 && !path.is_absolute();
+    let located = if bare {
+        which::which(path).ok()
+    } else {
+        Some(path.to_path_buf())
+    };
+    located
+        .and_then(|path| path.canonicalize().ok())
+        .is_some_and(|canonical| names_adapter(&canonical))
+}
+
+fn names_adapter(path: &Path) -> bool {
+    [path.file_name(), path.file_stem()]
+        .into_iter()
+        .flatten()
+        .any(|name| {
+            name.to_str()
+                .is_some_and(|name| ADAPTER_NAMES.contains(&name))
+        })
+}
+
+fn arg_names_adapter(arg: &str) -> bool {
+    let arg = arg.strip_prefix("--package=").unwrap_or(arg);
+    arg.split('/')
+        .any(|component| ADAPTER_NAMES.contains(&component))
+        || names_adapter(Path::new(arg))
+        || ADAPTER_PACKAGES
+            .iter()
+            .any(|package| holds_package(arg, package))
+}
+
+/// `package` at the start of `arg` or after a `/`, followed by nothing, a `/` or an `@version`.
+fn holds_package(arg: &str, package: &str) -> bool {
+    arg.match_indices(package).any(|(at, _)| {
+        let starts_component = at == 0 || arg[..at].ends_with('/');
+        let rest = &arg[at + package.len()..];
+        starts_component && (rest.is_empty() || rest.starts_with('/') || rest.starts_with('@'))
+    })
+}
+
+/// What happens to ai-memory inside the jail: memory off → `Off`; an endpoint that is not
+/// 127.0.0.1, ::1 or localhost → `NonLoopback`; an ai-jail without `--forward-port` →
+/// `Unsupported` (R7.4); else the port to forward. An unparsable endpoint while memory is on is an
+/// error. Shared by a start and the settings page, so both say the same.
+pub fn memory_port(
+    settings: &AgentMemorySettings,
+    supports_forward_port: bool,
+) -> Result<MemoryPort, InvalidMemoryEndpoint> {
+    if !settings.enabled {
+        return Ok(MemoryPort::Off);
+    }
+    let endpoint = url::Url::parse(&settings.endpoint).map_err(|_| InvalidMemoryEndpoint {
+        shown: redact_endpoint(&settings.endpoint),
+    })?;
+    let Some(port) = crate::memory::forward_port(&endpoint) else {
+        return Ok(MemoryPort::NonLoopback);
+    };
+    if !supports_forward_port {
+        return Ok(MemoryPort::Unsupported);
+    }
+    Ok(MemoryPort::Forwarded(port))
+}
+
+/// An unparsable endpoint as it may be shown: no query, no fragment, no userinfo, and no scheme
+/// that carries an `@` (a token pasted where the scheme goes).
+fn redact_endpoint(raw: &str) -> String {
+    let text = raw.split(['?', '#']).next().unwrap_or_default();
+    let (scheme, rest) = match text.split_once("://") {
+        Some((scheme, rest)) if !scheme.contains('@') => (format!("{scheme}://"), rest),
+        Some((_, rest)) => (String::new(), rest),
+        None => (String::new(), text),
+    };
+    let (authority, path) = match rest.find('/') {
+        Some(slash) => rest.split_at(slash),
+        None => (rest, ""),
+    };
+    let host = authority
+        .rsplit_once('@')
+        .map_or(authority, |(_, host)| host);
+    format!("{scheme}{host}{path}")
+}
+
+fn settings_fingerprint(sandbox: &AgentSandboxSettings, policy: &ProjectPolicy) -> u64 {
+    let mut hasher = DefaultHasher::new();
+    policy.enabled.hash(&mut hasher);
+    policy.rw_maps.hash(&mut hasher);
+    policy.allow_hosts.hash(&mut hasher);
+    sandbox.claude_config_dir.hash(&mut hasher);
+    sandbox.ai_jail_path.hash(&mut hasher);
+    hasher.finish()
+}
+
+fn memory_fingerprint(memory: &AgentMemorySettings) -> u64 {
+    let mut hasher = DefaultHasher::new();
+    memory.enabled.hash(&mut hasher);
+    memory.endpoint.hash(&mut hasher);
+    hasher.finish()
+}
+
+/// Whether the sandbox settings of the record's project differ from those it was started with
+/// (R1.12, R3.8, R5.4). Never for an agent the sandbox does not manage.
+pub fn drifted(record: &LaunchRecord, settings: &AgentSandboxSettings) -> bool {
+    match record.reason {
+        Reason::OwnCommand | Reason::Remote | Reason::NotClaude => false,
+        Reason::On | Reason::Off => {
+            let key = record
+                .settings_key
+                .as_deref()
+                .filter(|key| settings.projects.contains_key(*key))
+                .or_else(|| lexical_match(settings.project_keys(), &record.project_root));
+            settings_fingerprint(settings, &settings.policy_for_key(key))
+                != record.settings_fingerprint
+        }
+    }
+}
+
+/// Whether the memory settings differ from those a jailed agent was started with.
+pub fn memory_drifted(record: &LaunchRecord, settings: &AgentMemorySettings) -> bool {
+    record.reason == Reason::On && memory_fingerprint(settings) != record.memory_fingerprint
+}
+
+/// [`drifted`] or [`memory_drifted`] against the settings of now.
+pub fn drifted_now(record: &LaunchRecord, cx: &App) -> bool {
+    drifted(record, AgentSandboxSettings::get_global(cx))
+        || memory_drifted(record, AgentMemorySettings::get_global(cx))
+}
+
+/// Whether the ai-jail a start would use now (`None`: not found) differs from the jailed
+/// record's. The caller probes; this never runs a process.
+pub fn binary_drifted(
+    record: &LaunchRecord,
+    path: Option<&Path>,
+    version: Option<&Version>,
+) -> bool {
+    record.reason == Reason::On
+        && (record.ai_jail_path.as_deref() != path || record.ai_jail_version.as_ref() != version)
+}
+
+/// One registered launch.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub struct LaunchId(u64);
+
+/// The launch records of the running agents, written when an agent starts and removed when its
+/// connection ends.
+#[derive(Debug, Default)]
+pub struct LaunchRegistry {
+    next_id: u64,
+    launches: Vec<(LaunchId, LaunchRecord)>,
+}
+
+impl Global for LaunchRegistry {}
+
+impl LaunchRegistry {
+    pub fn register(record: LaunchRecord, cx: &mut App) -> LaunchId {
+        let registry = cx.default_global::<Self>();
+        registry.next_id += 1;
+        let id = LaunchId(registry.next_id);
+        registry.launches.push((id, record));
+        id
+    }
+
+    /// The latest live launch of `agent_id` in `root`.
+    pub fn get(agent_id: &AgentId, root: &Path, cx: &App) -> Option<LaunchRecord> {
+        cx.try_global::<Self>()?
+            .launches
+            .iter()
+            .rev()
+            .find(|(_, record)| record.agent_id == *agent_id && record.project_root == root)
+            .map(|(_, record)| record.clone())
+    }
+
+    /// Removes every launch of `agent_id` in `root`. Test-only: a connection ends its own launch
+    /// with [`Self::remove_launch`], so a newer launch of the same agent and root survives.
+    #[cfg(test)]
+    pub fn remove(agent_id: &AgentId, root: &Path, cx: &mut App) {
+        if cx.has_global::<Self>() {
+            cx.global_mut::<Self>()
+                .launches
+                .retain(|(_, record)| record.agent_id != *agent_id || record.project_root != root);
+        }
+    }
+
+    /// Removes this launch only; a later launch of the same agent and root stays.
+    pub fn remove_launch(id: LaunchId, cx: &mut App) {
+        if cx.has_global::<Self>() {
+            cx.global_mut::<Self>()
+                .launches
+                .retain(|(launch, _)| *launch != id);
+        }
+    }
+
+    /// Every live launch in `root`, oldest first.
+    pub fn for_root(root: &Path, cx: &App) -> Vec<LaunchRecord> {
+        cx.try_global::<Self>()
+            .map(|registry| {
+                registry
+                    .launches
+                    .iter()
+                    .filter(|(_, record)| record.project_root == root)
+                    .map(|(_, record)| record.clone())
+                    .collect()
+            })
+            .unwrap_or_default()
+    }
+}
diff --git a/crates/agent_sandbox/src/launch_tests.rs b/crates/agent_sandbox/src/launch_tests.rs
new file mode 100644
index 0000000..cc7ef46
--- /dev/null
+++ b/crates/agent_sandbox/src/launch_tests.rs
@@ -0,0 +1,1670 @@
+// Story 043, tasks 3.1 and 6.3 — the launch decision, its refusals, drift, and the forwarded
+// memory port (R1.1, R1.3, R1.4, R1.5, R1.6, R1.7, R1.8, R1.9, R1.10, R1.11, R1.12, R3.8, R5.4,
+// R7.1, R7.2, R7.3; Q4). Wired in the crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod launch_tests;
+//
+// Validation filters: `launch` (3.1, every test path is `launch_tests::…`) and `launch_memory`
+// (6.3, the `launch_memory_*` tests).
+//
+// Contract (design.md §agent_sandbox::launch). Assumed signatures — adjust the `use`/call only:
+//   - `crate::launch::decide(agent_id: AgentId, project: &Entity<Project>, cmd: AgentServerCommand,
+//      cx: &mut AsyncApp) -> LaunchDecision` (async)
+//   - `LaunchDecision::{Jailed(LaunchRecord, AgentServerCommand), Unchanged(LaunchRecord), Refused(LaunchError)}`
+//   - `LaunchRecord { agent_id, project_root, reason: Reason, ai_jail_version: Option<Version>,
+//      rw_maps: Vec<PathBuf>, allow_hosts: Vec<String>, forward_ports: Vec<u16>, settings_fingerprint }`
+//   - `Reason::{On, Off, OwnCommand, Remote}`; `LaunchError: Display`
+//   - `crate::launch::drifted(&LaunchRecord, &AgentSandboxSettings) -> bool`
+//
+// The project is on the real filesystem (RealFs) because the fake `ai-jail` must run as a real
+// process and `.zed` creation must be observable on disk.
+//
+// Hostile halves first:
+//   - own command, wrongly COLLAPSE: `/opt/x/ai-jailer` and `/opt/x/ai-jail-wrapper` are NOT
+//     ai-jail and are wrapped; wrongly SPLIT: `ai-jail` under any directory is own-command, even
+//     with the sandbox off and with no managed ai-jail installed.
+//   - refusals, wrongly START: every refusal path (missing binary, unreadable version, missing
+//     Claude dir, invalid map, invalid host, unparsable memory endpoint) ends in `Refused` whose
+//     text ends with "The agent was not started without the sandbox." and names the cause.
+//   - dedup, wrongly SPLIT: the same folder spelled through a symlink, with a trailing slash, or
+//     equal to the Claude config dir appears once as `--rw-map`.
+//   - drift, wrongly COLLAPSE: another project's change is not drift for this one; wrongly
+//     SPLIT: reverting the change clears the drift.
+//   - memory port, wrongly FORWARD: a non-loopback endpoint, or one whose host merely starts with
+//     `127.0.0.1`/`localhost`, adds no `--forward-port`; memory off adds none.
+
+use std::os::unix::fs::{PermissionsExt as _, symlink};
+use std::path::{Path, PathBuf};
+
+use collections::HashMap;
+use fs::RealFs;
+use gpui::{AppContext as _, Entity, TestAppContext, UpdateGlobal as _};
+use project::Project;
+use project::agent_registry_store::ZEO_PLUS_AGENT_ID;
+use project::agent_server_store::{AgentId, AgentServerCommand};
+use serde_json::{Value, json};
+use settings::{Settings as _, SettingsStore};
+
+use crate::AgentSandboxSettings;
+use crate::launch::{LaunchDecision, LaunchRecord, Reason, decide, drifted};
+
+struct Fixture {
+    _tmp: tempfile::TempDir,
+    base: PathBuf,
+    root: PathBuf,
+    ai_jail: PathBuf,
+    claude_dir: PathBuf,
+}
+
+fn write_script(path: &Path, body: &str) {
+    std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
+    std::fs::write(path, format!("#!/bin/sh\n{body}\n")).expect("write script");
+    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
+}
+
+fn fixture(version_line: &str) -> Fixture {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = tmp.path().canonicalize().expect("canonical");
+    let root = base.join("app");
+    std::fs::create_dir_all(root.join(".git")).expect("project");
+    let ai_jail = base.join("bin/ai-jail");
+    write_script(&ai_jail, &format!("echo '{version_line}'"));
+    let claude_dir = base.join("claude-jailed");
+    std::fs::create_dir_all(&claude_dir).expect("claude dir");
+    std::fs::create_dir_all(base.join("data/cache")).expect("data");
+    Fixture {
+        _tmp: tmp,
+        base,
+        root,
+        ai_jail,
+        claude_dir,
+    }
+}
+
+fn init(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        crate::init(cx);
+    });
+}
+
+fn set_user_settings(value: Value, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&value.to_string(), cx)
+                .result()
+                .expect("user settings parse");
+        })
+    });
+    cx.run_until_parked();
+}
+
+/// Sandbox settings for `f`, project entry merged from `project`, memory off unless overridden.
+fn sandbox(f: &Fixture, default_on: bool, project: Value, memory: Value) -> Value {
+    let mut projects = serde_json::Map::new();
+    if !project.is_null() {
+        projects.insert(f.root.display().to_string(), project);
+    }
+    json!({
+        "agent_sandbox": {
+            "enabled": default_on,
+            "ai_jail_path": f.ai_jail,
+            "claude_config_dir": f.claude_dir,
+            "rw_maps": [],
+            "allow_hosts": ["anthropic.com", "platform.claude.com"],
+            "projects": projects,
+        },
+        "agent_memory": memory,
+    })
+}
+
+fn memory_off() -> Value {
+    json!({ "enabled": false })
+}
+
+async fn project(f: &Fixture, cx: &mut TestAppContext) -> Entity<Project> {
+    let fs = RealFs::new(None, cx.executor());
+    let project = Project::test(fs, [f.root.as_path()], cx).await;
+    cx.run_until_parked();
+    project
+}
+
+fn agent_cmd(path: &str) -> AgentServerCommand {
+    AgentServerCommand {
+        path: PathBuf::from(path),
+        args: vec!["--acp".into()],
+        env: Some(HashMap::from_iter([
+            (
+                "ZED_SECRET".to_string(),
+                "value-must-stay-off-argv".to_string(),
+            ),
+            ("ANTHROPIC_API_KEY".to_string(), String::new()),
+        ])),
+    }
+}
+
+async fn run(
+    project: &Entity<Project>,
+    cmd: AgentServerCommand,
+    cx: &mut TestAppContext,
+) -> LaunchDecision {
+    let mut async_cx = cx.to_async();
+    let configured_env = configured_env(&cmd);
+    decide(
+        AgentId::new(ZEO_PLUS_AGENT_ID),
+        project,
+        cmd,
+        &configured_env,
+        &mut async_cx,
+    )
+    .await
+}
+
+/// Every name of the command's env, as if the agent's settings configured each one.
+fn configured_env(cmd: &AgentServerCommand) -> std::collections::HashSet<String> {
+    cmd.env.iter().flat_map(|env| env.keys().cloned()).collect()
+}
+
+fn jailed(decision: LaunchDecision) -> (LaunchRecord, AgentServerCommand) {
+    match decision {
+        LaunchDecision::Jailed(record, cmd) => (record, cmd),
+        LaunchDecision::Unchanged(_) => panic!("expected Jailed, got Unchanged"),
+        LaunchDecision::Refused(error) => panic!("expected Jailed, got Refused: {error}"),
+    }
+}
+
+fn unchanged(decision: LaunchDecision) -> LaunchRecord {
+    match decision {
+        LaunchDecision::Unchanged(record) => record,
+        LaunchDecision::Jailed(_, cmd) => panic!("expected Unchanged, got Jailed {:?}", cmd.args),
+        LaunchDecision::Refused(error) => panic!("expected Unchanged, got Refused: {error}"),
+    }
+}
+
+fn refused(decision: LaunchDecision) -> String {
+    match decision {
+        LaunchDecision::Refused(error) => {
+            let text = format!("{error:#}");
+            assert!(
+                text.trim_end()
+                    .ends_with("The agent was not started without the sandbox."),
+                "every refusal ends with the fail-closed sentence: {text}"
+            );
+            text
+        }
+        LaunchDecision::Jailed(_, cmd) => panic!("expected Refused, got Jailed {:?}", cmd.args),
+        LaunchDecision::Unchanged(_) => {
+            panic!("expected Refused, got Unchanged (an unjailed start)")
+        }
+    }
+}
+
+fn count(args: &[String], flag: &str, value: &str) -> usize {
+    args.windows(2)
+        .filter(|pair| pair[0] == flag && pair[1] == value)
+        .count()
+}
+
+// --- own command (R1.7) -----------------------------------------------------------------------
+
+#[gpui::test]
+async fn launch_lookalike_names_are_not_own_command(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+    let project = project(&f, cx).await;
+    for path in ["/opt/x/ai-jailer", "/opt/x/ai-jail-wrapper"] {
+        let (record, cmd) = jailed(run(&project, agent_cmd(path), cx).await);
+        assert!(matches!(record.reason, Reason::On), "{path}");
+        assert_eq!(cmd.path, f.ai_jail, "{path} is wrapped");
+        assert_eq!(cmd.args.last().map(String::as_str), Some("--acp"));
+    }
+}
+
+#[gpui::test]
+async fn launch_own_command_is_never_wrapped_or_refused(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    std::fs::remove_file(&f.ai_jail).expect("no managed ai-jail at all");
+    for default_on in [true, false] {
+        set_user_settings(sandbox(&f, default_on, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        let record = unchanged(
+            run(
+                &project,
+                agent_cmd("/home/u/ai-jail/fork/target/release/ai-jail"),
+                cx,
+            )
+            .await,
+        );
+        assert!(
+            matches!(record.reason, Reason::OwnCommand),
+            "own command with the sandbox {} → OwnCommand",
+            if default_on { "on" } else { "off" }
+        );
+    }
+}
+
+// --- refusals (R1.9–R1.11, Q4) ------------------------------------------------------------------
+
+#[gpui::test]
+async fn launch_refuses_without_ai_jail(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    std::fs::remove_file(&f.ai_jail).expect("remove");
+    set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+    let project = project(&f, cx).await;
+    let text = refused(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    assert!(
+        text.contains(&f.root.display().to_string()),
+        "names the project root: {text}"
+    );
+}
+
+#[gpui::test]
+async fn launch_refuses_an_unreadable_version(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("not a version");
+    set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+    let project = project(&f, cx).await;
+    refused(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+}
+
+#[gpui::test]
+async fn launch_refuses_a_missing_claude_dir_with_the_login_command(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    std::fs::remove_dir(&f.claude_dir).expect("remove");
+    set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+    let project = project(&f, cx).await;
+    let text = refused(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    let dir = f.claude_dir.display().to_string();
+    assert!(text.contains(&dir), "names the directory: {text}");
+    assert!(
+        text.contains(&format!("CLAUDE_CONFIG_DIR={dir} claude")),
+        "gives the login command: {text}"
+    );
+}
+
+#[gpui::test]
+async fn launch_refuses_invalid_entries_naming_them(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    let project_entity = {
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        project(&f, cx).await
+    };
+    let bad_map = format!("{}/data:x", f.base.display());
+    for (entry, project) in [
+        (bad_map.as_str(), json!({ "rw_maps": [bad_map.clone()] })),
+        (
+            "Registry.npmjs.org",
+            json!({ "allow_hosts": ["Registry.npmjs.org"] }),
+        ),
+        ("127.0.0.1", json!({ "allow_hosts": ["127.0.0.1"] })),
+    ] {
+        set_user_settings(sandbox(&f, true, project, memory_off()), cx);
+        let text = refused(
+            run(
+                &project_entity,
+                agent_cmd("/usr/bin/claude-agent-acp-plus"),
+                cx,
+            )
+            .await,
+        );
+        assert!(text.contains(entry), "names the entry {entry:?}: {text}");
+    }
+}
+
+// --- on / off / outdated (R1.1, R1.6, A4) -------------------------------------------------------
+
+#[gpui::test]
+async fn launch_off_leaves_the_command_and_the_project_untouched(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    set_user_settings(sandbox(&f, false, Value::Null, memory_off()), cx);
+    let project = project(&f, cx).await;
+    let record = unchanged(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    assert!(matches!(record.reason, Reason::Off));
+    assert!(
+        !f.root.join(".zed").exists(),
+        "no .zed is created for an unjailed start"
+    );
+
+    // The project's own `enabled: false` wins over a default `on`.
+    set_user_settings(
+        sandbox(&f, true, json!({ "enabled": false }), memory_off()),
+        cx,
+    );
+    let record = unchanged(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    assert!(matches!(record.reason, Reason::Off));
+}
+
+#[gpui::test]
+async fn launch_on_wraps_the_agent_and_creates_an_empty_dot_zed(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    set_user_settings(
+        sandbox(&f, false, json!({ "enabled": true }), memory_off()),
+        cx,
+    );
+    let project = project(&f, cx).await;
+    let original = agent_cmd("/usr/bin/claude-agent-acp-plus");
+    let (record, cmd) = jailed(run(&project, original.clone(), cx).await);
+
+    assert!(matches!(record.reason, Reason::On));
+    assert_eq!(record.project_root, f.root);
+    assert_eq!(record.ai_jail_version, Some(semver::Version::new(2, 8, 1)));
+    assert_eq!(cmd.path, f.ai_jail);
+    assert_eq!(cmd.args.first().map(String::as_str), Some("--exec"));
+    let agent_at = cmd
+        .args
+        .iter()
+        .position(|arg| arg == "/usr/bin/claude-agent-acp-plus")
+        .expect("agent path on argv");
+    assert_eq!(
+        &cmd.args[agent_at + 1..],
+        &original.args[..],
+        "agent args unchanged"
+    );
+    assert_eq!(
+        cmd.env, original.env,
+        "env handed over through the environment"
+    );
+    assert_eq!(count(&cmd.args, "--env", "ANTHROPIC_API_KEY"), 1);
+    assert_eq!(count(&cmd.args, "--env", "ZED_SECRET"), 1);
+    assert!(
+        !cmd.args
+            .iter()
+            .any(|arg| arg.contains("value-must-stay-off-argv"))
+    );
+    let env_names: Vec<&String> = cmd
+        .args
+        .windows(2)
+        .filter(|pair| pair[0] == "--env")
+        .map(|pair| &pair[1])
+        .collect();
+    let mut sorted = env_names.clone();
+    sorted.sort();
+    assert_eq!(env_names, sorted, "--env names are sorted");
+
+    let dot_zed = f.root.join(".zed");
+    assert!(dot_zed.is_dir(), "R1.5: .zed created in the project root");
+    assert_eq!(
+        std::fs::read_dir(&dot_zed).expect("read .zed").count(),
+        0,
+        "and empty"
+    );
+}
+
+#[gpui::test]
+async fn launch_keeps_an_existing_dot_zed_untouched(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    std::fs::create_dir_all(f.root.join(".zed")).expect("mkdir");
+    std::fs::write(f.root.join(".zed/tasks.json"), "[]").expect("write");
+    set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+    let project = project(&f, cx).await;
+    jailed(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    assert_eq!(
+        std::fs::read_to_string(f.root.join(".zed/tasks.json")).expect("read"),
+        "[]"
+    );
+}
+
+#[gpui::test]
+async fn launch_outdated_ai_jail_still_starts_jailed(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.3.9");
+    set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+    let project = project(&f, cx).await;
+    let (record, _) = jailed(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    assert_eq!(
+        record.ai_jail_version,
+        Some(semver::Version::new(2, 3, 9)),
+        "A4: warn, start"
+    );
+}
+
+// --- dedup (R1.3) -------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn launch_dedups_every_spelling_of_one_folder(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    let cache = f.base.join("data/cache");
+    symlink(&cache, f.base.join("cache-link")).expect("symlink");
+    let mut settings = sandbox(
+        &f,
+        true,
+        json!({ "rw_maps": [
+            format!("{}/", cache.display()),
+            f.base.join("cache-link").display().to_string(),
+            f.claude_dir.display().to_string(),
+        ] }),
+        memory_off(),
+    );
+    settings["agent_sandbox"]["rw_maps"] = json!([cache.display().to_string()]);
+    set_user_settings(settings, cx);
+    let project = project(&f, cx).await;
+    let (_, cmd) = jailed(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    assert_eq!(
+        count(&cmd.args, "--rw-map", &cache.display().to_string()),
+        1,
+        "{:?}",
+        cmd.args
+    );
+    assert_eq!(
+        count(&cmd.args, "--rw-map", &f.claude_dir.display().to_string()),
+        1,
+        "the Claude dir is mapped once: {:?}",
+        cmd.args
+    );
+    assert_eq!(
+        cmd.args.iter().filter(|arg| *arg == "--rw-map").count(),
+        2,
+        "exactly the Claude dir and the cache: {:?}",
+        cmd.args
+    );
+}
+
+// --- remote (R1.8) ------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn launch_remote_project_is_unchanged_even_when_refusal_would_apply(
+    cx: &mut TestAppContext,
+    server_cx: &mut TestAppContext,
+) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    std::fs::remove_file(&f.ai_jail).expect("no ai-jail: a local project would be refused");
+    set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+    server_cx.update(|cx| release_channel::init(semver::Version::new(0, 0, 0), cx));
+    let params = cx.update(workspace::AppState::test);
+    let (opts, server_session, connect_guard) = remote::RemoteClient::fake_server(cx, server_cx);
+    // `RemoteClient::new` pings the server before it connects.
+    let anchor = server_cx.new(|_| ());
+    server_session
+        .add_request_handler::<proto::Ping, _, _, _>(anchor.downgrade(), |_, _, _| async {
+            Ok(proto::Ack {})
+        });
+    drop(connect_guard);
+    let remote_client = remote::RemoteClient::connect_mock(opts, cx).await;
+    let project = cx.update(|cx| {
+        Project::remote(
+            remote_client,
+            params.client.clone(),
+            params.node_runtime.clone(),
+            params.user_store.clone(),
+            params.languages.clone(),
+            params.fs.clone(),
+            false,
+            cx,
+        )
+    });
+    let record = unchanged(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    assert!(matches!(record.reason, Reason::Remote));
+}
+
+// --- drift (R1.12, R3.8, R5.4) --------------------------------------------------------------------
+
+#[gpui::test]
+async fn launch_drift_is_per_project_and_clears_on_revert(cx: &mut TestAppContext) {
+    init(cx);
+    let f = fixture("ai-jail 2.8.1");
+    let started_with = sandbox(
+        &f,
+        true,
+        json!({ "allow_hosts": ["registry.npmjs.org"] }),
+        memory_off(),
+    );
+    set_user_settings(started_with.clone(), cx);
+    let project = project(&f, cx).await;
+    let (record, _) = jailed(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+    let is_drifted = |cx: &mut TestAppContext| {
+        cx.update(|cx| drifted(&record, AgentSandboxSettings::get_global(cx)))
+    };
+    assert!(!is_drifted(cx), "no drift right after the start");
+
+    let mut other = started_with.clone();
+    other["agent_sandbox"]["projects"]["/somewhere/else"] =
+        json!({ "enabled": false, "allow_hosts": ["x.example.com"] });
+    set_user_settings(other, cx);
+    assert!(
+        !is_drifted(cx),
+        "another project's change is not drift for this one"
+    );
+
+    let mut changed = started_with.clone();
+    changed["agent_sandbox"]["projects"][f.root.display().to_string()]["allow_hosts"] =
+        json!(["registry.npmjs.org", "crates.io"]);
+    set_user_settings(changed, cx);
+    assert!(is_drifted(cx), "a new host for this project is drift");
+
+    let mut off = started_with.clone();
+    off["agent_sandbox"]["projects"][f.root.display().to_string()]["enabled"] = json!(false);
+    set_user_settings(off, cx);
+    assert!(is_drifted(cx), "turning the project off is drift");
+
+    set_user_settings(started_with, cx);
+    assert!(!is_drifted(cx), "reverting clears the drift");
+}
+
+// --- memory port (6.3: R7.1, R7.2, R7.3) ------------------------------------------------------------
+
+async fn ports_for(memory: Value, cx: &mut TestAppContext) -> Result<Vec<String>, String> {
+    let f = fixture("ai-jail 2.8.1");
+    // An ai-jail whose --help lists --forward-port (the fork's build); 2.8.1's own help does not.
+    write_script(
+        &f.ai_jail,
+        "case \"$1\" in --help) printf '    --forward-port <PORT>  Forward a host port\\n' ;; *) echo 'ai-jail 2.8.1' ;; esac",
+    );
+    set_user_settings(sandbox(&f, true, Value::Null, memory), cx);
+    let project = project(&f, cx).await;
+    match run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await {
+        LaunchDecision::Jailed(record, cmd) => {
+            let ports: Vec<String> = cmd
+                .args
+                .windows(2)
+                .filter(|pair| pair[0] == "--forward-port")
+                .map(|pair| pair[1].clone())
+                .collect();
+            assert_eq!(
+                record
+                    .forward_ports
+                    .iter()
+                    .map(u16::to_string)
+                    .collect::<Vec<_>>(),
+                ports,
+                "the record carries the ports on argv"
+            );
+            Ok(ports)
+        }
+        LaunchDecision::Refused(error) => Err(format!("{error:#}")),
+        LaunchDecision::Unchanged(_) => panic!("sandbox on: never Unchanged"),
+    }
+}
+
+#[gpui::test]
+async fn launch_memory_never_forwards_a_non_loopback_endpoint(cx: &mut TestAppContext) {
+    init(cx);
+    for endpoint in [
+        "http://mem.example.com:8080/mcp",
+        "http://127.0.0.1.example.com:9000/mcp",
+        "http://localhost.example.com:9000/mcp",
+        "http://192.168.1.10:49374/mcp",
+    ] {
+        assert_eq!(
+            ports_for(json!({ "enabled": true, "endpoint": endpoint }), cx).await,
+            Ok(vec![]),
+            "{endpoint} is not loopback"
+        );
+    }
+}
+
+#[gpui::test]
+async fn launch_memory_off_forwards_nothing(cx: &mut TestAppContext) {
+    init(cx);
+    assert_eq!(
+        ports_for(
+            json!({ "enabled": false, "endpoint": "http://127.0.0.1:49374/mcp" }),
+            cx
+        )
+        .await,
+        Ok(vec![])
+    );
+}
+
+#[gpui::test]
+async fn launch_memory_on_forwards_the_loopback_port(cx: &mut TestAppContext) {
+    init(cx);
+    for (endpoint, port) in [
+        ("http://127.0.0.1:49374/mcp", "49374"),
+        ("http://localhost:7000/mcp", "7000"),
+        ("http://[::1]:5000/mcp", "5000"),
+        ("http://127.0.0.1/mcp", "80"),
+    ] {
+        assert_eq!(
+            ports_for(json!({ "enabled": true, "endpoint": endpoint }), cx).await,
+            Ok(vec![port.to_string()]),
+            "{endpoint}"
+        );
+    }
+}
+
+#[gpui::test]
+async fn launch_memory_unparsable_endpoint_refuses(cx: &mut TestAppContext) {
+    init(cx);
+    let text = ports_for(json!({ "enabled": true, "endpoint": "not a url" }), cx)
+        .await
+        .expect_err("an unparsable endpoint refuses the launch");
+    assert!(text.contains("not a url"), "names the entry: {text}");
+    assert!(
+        text.trim_end()
+            .ends_with("The agent was not started without the sandbox.")
+    );
+}
+
+#[gpui::test]
+async fn launch_memory_unparsable_endpoint_text_carries_no_token(cx: &mut TestAppContext) {
+    init(cx);
+    let text = ports_for(
+        json!({ "enabled": true, "endpoint": "a@s3cret://mem host/x?token=t#f" }),
+        cx,
+    )
+    .await
+    .expect_err("an unparsable endpoint refuses the launch");
+    assert!(text.contains("mem host/x"), "still names the entry: {text}");
+    for secret in ["s3cret", "token=t", "#f"] {
+        assert!(!text.contains(secret), "{secret} leaked into: {text}");
+    }
+}
+
+// --- authored at Run (3.1): review findings and v2 rules, Red first --------------------------------
+
+/// A launch for `agent` with `configured` as the names the agent's settings configure.
+async fn run_with(
+    agent: &str,
+    project: &Entity<Project>,
+    cmd: AgentServerCommand,
+    configured: &[&str],
+    cx: &mut TestAppContext,
+) -> LaunchDecision {
+    let mut async_cx = cx.to_async();
+    let configured: std::collections::HashSet<String> =
+        configured.iter().map(|name| name.to_string()).collect();
+    decide(
+        AgentId::new(agent),
+        project,
+        cmd,
+        &configured,
+        &mut async_cx,
+    )
+    .await
+}
+
+/// The refusal's text and its cause token.
+fn refusal(decision: LaunchDecision) -> (String, &'static str) {
+    match decision {
+        LaunchDecision::Refused(error) => {
+            let text = format!("{error:#}");
+            assert!(
+                text.trim_end()
+                    .ends_with("The agent was not started without the sandbox."),
+                "every refusal ends with the fail-closed sentence: {text}"
+            );
+            (text, error.cause.as_str())
+        }
+        LaunchDecision::Jailed(_, cmd) => panic!("expected Refused, got Jailed {:?}", cmd.args),
+        LaunchDecision::Unchanged(record) => {
+            panic!("expected Refused, got Unchanged {:?}", record.reason)
+        }
+    }
+}
+
+fn plain_cmd(path: &str, args: &[&str]) -> AgentServerCommand {
+    AgentServerCommand {
+        path: PathBuf::from(path),
+        args: args.iter().map(|arg| arg.to_string()).collect(),
+        env: None,
+    }
+}
+
+mod run_additions {
+    use super::*;
+    use crate::launch::{
+        LaunchCause, LaunchEnv, LaunchRegistry, MemoryPort, check_root, decide_in,
+    };
+    use crate::validate::ProtectedSet;
+
+    /// A home of its own under the fixture: `.config/zeo`, `.ssh`, `.claude-jailed`, `src/app`.
+    fn fake_home(f: &Fixture) -> PathBuf {
+        let home = f.base.join("home");
+        for dir in [".config/zeo", ".ssh", ".claude-jailed", "src/app/.git"] {
+            std::fs::create_dir_all(home.join(dir)).expect("home layout");
+        }
+        home
+    }
+
+    fn home_env(home: &Path) -> LaunchEnv {
+        LaunchEnv {
+            home: home.to_path_buf(),
+            config_dir: home.join(".config/zeo"),
+            runtime_dir: None,
+        }
+    }
+
+    fn home_settings(f: &Fixture, claude_dir: &str) -> Value {
+        json!({
+            "agent_sandbox": {
+                "enabled": true,
+                "ai_jail_path": f.ai_jail,
+                "claude_config_dir": claude_dir,
+                "rw_maps": [],
+                "allow_hosts": ["anthropic.com"],
+                "projects": {},
+            },
+            "agent_memory": memory_off(),
+        })
+    }
+
+    async fn project_at(root: &Path, cx: &mut TestAppContext) -> Entity<Project> {
+        let fs = RealFs::new(None, cx.executor());
+        let project = Project::test(fs, [root], cx).await;
+        cx.run_until_parked();
+        project
+    }
+
+    async fn run_home(
+        home: &Path,
+        project: &Entity<Project>,
+        cx: &mut TestAppContext,
+    ) -> LaunchDecision {
+        let cmd = plain_cmd("/usr/bin/claude-agent-acp-plus", &["--acp"]);
+        let mut async_cx = cx.to_async();
+        decide_in(
+            AgentId::new(ZEO_PLUS_AGENT_ID),
+            project,
+            cmd,
+            &Default::default(),
+            home_env(home),
+            &mut async_cx,
+        )
+        .await
+    }
+
+    #[gpui::test]
+    async fn launch_root_home_slash_and_config_are_refused_a_project_is_not(
+        cx: &mut TestAppContext,
+    ) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        let home = fake_home(&f);
+        set_user_settings(home_settings(&f, "~/.claude-jailed"), cx);
+
+        let project = project_at(&home, cx).await;
+        let (text, cause) = refusal(run_home(&home, &project, cx).await);
+        assert_eq!(cause, "root_forbidden", "{text}");
+        assert!(
+            text.contains(&home.display().to_string()),
+            "names the root: {text}"
+        );
+        assert!(text.contains("home directory"), "names the rule: {text}");
+
+        let config = home.join(".config");
+        let project = project_at(&config, cx).await;
+        let (text, cause) = refusal(run_home(&home, &project, cx).await);
+        assert_eq!(cause, "root_forbidden", "{text}");
+        assert!(text.contains(&config.display().to_string()), "{text}");
+
+        let set = ProtectedSet::new(&home, &home.join(".config/zeo"), None);
+        assert!(
+            matches!(
+                check_root(Path::new("/"), &set),
+                Err(LaunchCause::RootForbidden { .. })
+            ),
+            "/ is refused"
+        );
+
+        let app = home.join("src/app");
+        let project = project_at(&app, cx).await;
+        let (record, _) = jailed(run_home(&home, &project, cx).await);
+        assert_eq!(record.project_root, app, "~/src/app is accepted");
+    }
+
+    #[gpui::test]
+    async fn launch_claude_dir_rules(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        let home = fake_home(&f);
+        std::fs::create_dir_all(home.join(".claude:x")).expect("colon dir");
+        let app = home.join("src/app");
+        let project = project_at(&app, cx).await;
+
+        for raw in ["~/.ssh", "~/.claude:x"] {
+            set_user_settings(home_settings(&f, raw), cx);
+            let (text, cause) = refusal(run_home(&home, &project, cx).await);
+            assert_eq!(cause, "claude_dir_invalid", "{raw}: {text}");
+            assert!(text.contains(raw), "names the directory {raw}: {text}");
+        }
+
+        set_user_settings(home_settings(&f, "~/.claude-jailed"), cx);
+        let (record, cmd) = jailed(run_home(&home, &project, cx).await);
+        let claude_dir = home.join(".claude-jailed");
+        assert_eq!(
+            record.rw_maps.first(),
+            Some(&claude_dir),
+            "claude dir first"
+        );
+        assert_eq!(
+            count(&cmd.args, "--claude-dir", &claude_dir.display().to_string()),
+            1
+        );
+    }
+
+    #[gpui::test]
+    async fn launch_without_a_root(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        let fs = RealFs::new(None, cx.executor());
+        let project = Project::test(fs, [] as [&Path; 0], cx).await;
+        cx.run_until_parked();
+        assert_eq!(
+            cx.update(|cx| crate::launch::project_root(&project, cx)),
+            None,
+            "no visible worktree is no root, not the home fallback"
+        );
+
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let (_, cause) =
+            refusal(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+        assert_eq!(cause, "no_root");
+
+        set_user_settings(sandbox(&f, false, Value::Null, memory_off()), cx);
+        let record =
+            unchanged(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+        assert!(matches!(record.reason, Reason::Off));
+    }
+
+    #[gpui::test]
+    async fn launch_refuses_thirty_three_maps_and_accepts_thirty_two(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        let maps: Vec<String> = (0..33)
+            .map(|index| {
+                let dir = f.base.join(format!("maps/m{index:02}"));
+                std::fs::create_dir_all(&dir).expect("map dir");
+                dir.display().to_string()
+            })
+            .collect();
+        let project = project(&f, cx).await;
+
+        set_user_settings(
+            sandbox(&f, true, json!({ "rw_maps": maps }), memory_off()),
+            cx,
+        );
+        let (text, cause) =
+            refusal(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+        assert_eq!(cause, "limit", "{text}");
+
+        set_user_settings(
+            sandbox(&f, true, json!({ "rw_maps": maps[..32] }), memory_off()),
+            cx,
+        );
+        let (record, _) =
+            jailed(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+        assert_eq!(record.rw_maps.len(), 33, "the claude dir is not counted");
+    }
+
+    #[gpui::test]
+    fn launch_registry_removes_only_its_own(cx: &mut TestAppContext) {
+        init(cx);
+        let record = |agent: &str, root: &str, version: u64| LaunchRecord {
+            agent_id: AgentId::new(agent.to_string()),
+            project_root: PathBuf::from(root),
+            reason: Reason::On,
+            ai_jail_version: Some(semver::Version::new(2, 8, version)),
+            ai_jail_path: Some(PathBuf::from("/usr/bin/ai-jail")),
+            rw_maps: vec![],
+            allow_hosts: vec![],
+            forward_ports: vec![],
+            memory_port: MemoryPort::Off,
+            settings_fingerprint: 0,
+            memory_fingerprint: 0,
+            settings_key: None,
+        };
+        cx.update(|cx| {
+            let first = LaunchRegistry::register(record("plus", "/w/app", 1), cx);
+            let second = LaunchRegistry::register(record("plus", "/w/app", 2), cx);
+            LaunchRegistry::register(record("tui", "/w/app", 3), cx);
+            LaunchRegistry::register(record("plus", "/w/other", 4), cx);
+            let plus = AgentId::new("plus");
+            let latest = |cx: &gpui::App| {
+                LaunchRegistry::get(&plus, Path::new("/w/app"), cx)
+                    .and_then(|record| record.ai_jail_version)
+            };
+            assert_eq!(
+                latest(cx),
+                Some(semver::Version::new(2, 8, 2)),
+                "the latest live"
+            );
+            LaunchRegistry::remove_launch(second, cx);
+            assert_eq!(
+                latest(cx),
+                Some(semver::Version::new(2, 8, 1)),
+                "only its own launch"
+            );
+            LaunchRegistry::remove_launch(first, cx);
+            assert_eq!(latest(cx), None);
+            assert_eq!(
+                LaunchRegistry::for_root(Path::new("/w/app"), cx).len(),
+                1,
+                "tui stays"
+            );
+
+            LaunchRegistry::register(record("plus", "/w/app", 5), cx);
+            LaunchRegistry::remove(&plus, Path::new("/w/app"), cx);
+            assert_eq!(latest(cx), None);
+            assert!(LaunchRegistry::get(&AgentId::new("tui"), Path::new("/w/app"), cx).is_some());
+            assert!(LaunchRegistry::get(&plus, Path::new("/w/other"), cx).is_some());
+        });
+    }
+
+    #[gpui::test]
+    async fn launch_non_claude_agents_start_unchanged(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        for (agent, cmd) in [
+            (
+                "gemini",
+                plain_cmd("/usr/bin/gemini", &["--experimental-acp"]),
+            ),
+            ("codex-acp", plain_cmd("codex-acp", &[])),
+            (
+                "claude-acp",
+                plain_cmd("npx", &["-y", "@zed-industries/claude-code-acp"]),
+            ),
+        ] {
+            let record = unchanged(run_with(agent, &project, cmd, &[], cx).await);
+            assert!(matches!(record.reason, Reason::NotClaude), "{agent}");
+        }
+        assert!(
+            !f.root.join(".zed").exists(),
+            "no .zed for an unmanaged agent"
+        );
+    }
+
+    #[gpui::test]
+    async fn launch_npm_plus_entry_is_recognised(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        let cmd = plain_cmd("npx", &["-y", "@lucascouts/claude-agent-acp-plus@latest"]);
+        let (record, jailed_cmd) = jailed(run_with("my-plus", &project, cmd, &[], cx).await);
+        assert!(matches!(record.reason, Reason::On));
+        assert_eq!(jailed_cmd.path, f.ai_jail);
+    }
+
+    #[gpui::test]
+    async fn launch_memory_without_forward_port_is_unsupported(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(
+            sandbox(
+                &f,
+                true,
+                Value::Null,
+                json!({ "enabled": true, "endpoint": "http://127.0.0.1:49374/mcp" }),
+            ),
+            cx,
+        );
+        let project = project(&f, cx).await;
+        let (record, cmd) =
+            jailed(run(&project, agent_cmd("/usr/bin/claude-agent-acp-plus"), cx).await);
+        assert_eq!(record.memory_port, MemoryPort::Unsupported);
+        assert!(record.forward_ports.is_empty());
+        assert!(
+            !cmd.args.iter().any(|arg| arg == "--forward-port"),
+            "{:?}",
+            cmd.args
+        );
+    }
+}
+
+mod review_fixes {
+    use super::*;
+    use crate::AgentMemorySettings;
+    use crate::launch::{LaunchCause, binary_drifted, check_nesting, check_root, memory_drifted};
+    use crate::validate::ProtectedSet;
+
+    fn plus() -> AgentServerCommand {
+        agent_cmd("/usr/bin/claude-agent-acp-plus")
+    }
+
+    #[gpui::test]
+    async fn launch_refuses_nested_writable_folders(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        std::fs::create_dir_all(f.root.join("sub")).expect("sub");
+        let project = project(&f, cx).await;
+        let data = f.base.join("data");
+        let cache = data.join("cache");
+        for maps in [vec![data.clone(), cache.clone()], vec![f.root.join("sub")]] {
+            let maps: Vec<String> = maps.iter().map(|map| map.display().to_string()).collect();
+            set_user_settings(
+                sandbox(&f, true, json!({ "rw_maps": maps }), memory_off()),
+                cx,
+            );
+            let (text, cause) = refusal(run(&project, plus(), cx).await);
+            assert_eq!(cause, "nested_map", "{maps:?}: {text}");
+        }
+        assert!(
+            matches!(
+                check_nesting(&f.root, &cache, std::slice::from_ref(&data)),
+                Err(LaunchCause::NestedMap { .. })
+            ),
+            "the claude dir inside a map"
+        );
+        assert!(
+            matches!(
+                check_nesting(&f.root, &f.root.join("sub"), &[]),
+                Err(LaunchCause::NestedMap { .. })
+            ),
+            "the claude dir inside the root"
+        );
+        assert!(check_nesting(&f.root, &f.claude_dir, std::slice::from_ref(&cache)).is_ok());
+    }
+
+    #[gpui::test]
+    async fn launch_refuses_a_bad_configured_env_key_without_its_value(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        for key in ["A=B", "1BAD", "BAD-KEY"] {
+            let mut cmd = plus();
+            cmd.env = Some(HashMap::from_iter([(
+                key.to_string(),
+                "secret-value".to_string(),
+            )]));
+            let (text, cause) =
+                refusal(run_with(ZEO_PLUS_AGENT_ID, &project, cmd, &[key], cx).await);
+            assert_eq!(cause, "invalid_env_key", "{key}: {text}");
+            assert!(!text.contains("secret-value"), "{text}");
+            let shown = key.split('=').next().unwrap_or(key);
+            assert!(text.contains(shown), "names the key: {text}");
+            assert!(!text.contains("A=B"), "only NAME=…: {text}");
+        }
+    }
+
+    #[gpui::test]
+    async fn launch_unconfigured_shell_vars_are_never_forwarded(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        let mut cmd = plus();
+        cmd.env = Some(HashMap::from_iter([
+            ("SECRET_X".to_string(), "x".to_string()),
+            ("BASH_FUNC_f%%".to_string(), "() { :; }".to_string()),
+            ("ANTHROPIC_API_KEY".to_string(), String::new()),
+        ]));
+        let (_, jailed_cmd) =
+            jailed(run_with(ZEO_PLUS_AGENT_ID, &project, cmd, &["ANTHROPIC_API_KEY"], cx).await);
+        let names: Vec<&str> = jailed_cmd
+            .args
+            .windows(2)
+            .filter(|pair| pair[0] == "--env")
+            .map(|pair| pair[1].as_str())
+            .collect();
+        assert_eq!(names, vec!["ANTHROPIC_API_KEY"]);
+    }
+
+    #[gpui::test]
+    async fn launch_jails_every_adapter_spelling(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        let real = f.base.join("real/claude-agent-acp-plus");
+        write_script(&real, "exit 0");
+        let link = f.base.join("bin/cap");
+        symlink(&real, &link).expect("symlink");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        let link = link.display().to_string();
+        for cmd in [
+            plain_cmd("/home/u/.npm-global/bin/claude-agent-tui", &[]),
+            plain_cmd(
+                "/usr/bin/node",
+                &["/usr/lib/node_modules/@lucascouts/claude-agent-acp-plus/dist/index.js"],
+            ),
+            plain_cmd("/opt/x/claude-agent-acp-plus.sh", &[]),
+            plain_cmd(&link, &[]),
+            plain_cmd(
+                "npx",
+                &[
+                    "--yes",
+                    "--package=@lucascouts/claude-agent-tui@1.2.0",
+                    "run",
+                ],
+            ),
+            plain_cmd(
+                "pnpm",
+                &["dlx", "-p", "@lucascouts/claude-agent-acp-plus", "start"],
+            ),
+            plain_cmd("bash", &["/srv/claude-agent-acp-plus/start"]),
+        ] {
+            let spelled = format!("{} {:?}", cmd.path.display(), cmd.args);
+            let (record, _) = jailed(run_with("custom", &project, cmd, &[], cx).await);
+            assert!(matches!(record.reason, Reason::On), "{spelled}");
+        }
+    }
+
+    #[gpui::test]
+    async fn launch_lookalike_adapters_are_not_claude(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        for cmd in [
+            plain_cmd("npx", &["-y", "@lucascouts/claude-agent-acp-plus-evil"]),
+            plain_cmd("npx", &["-y", "@lucascouts/claude-agent-tuix@1.0.0"]),
+            plain_cmd("/usr/bin/claude-agent-acp-plus-evil", &[]),
+            plain_cmd("/usr/bin/my-claude-agent-tui", &[]),
+            plain_cmd("/usr/bin/claude-acp", &["--acp"]),
+        ] {
+            let spelled = format!("{} {:?}", cmd.path.display(), cmd.args);
+            let record = unchanged(run_with("custom", &project, cmd, &[], cx).await);
+            assert!(matches!(record.reason, Reason::NotClaude), "{spelled}");
+        }
+    }
+
+    #[gpui::test]
+    async fn launch_refuses_symlinked_mounts(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let elsewhere = f.base.join("data");
+
+        std::fs::remove_dir(f.root.join(".git")).expect("rm .git");
+        symlink(&elsewhere, f.root.join(".git")).expect("symlink .git");
+        let project = project(&f, cx).await;
+        let (text, cause) = refusal(run(&project, plus(), cx).await);
+        assert_eq!(cause, "symlinked_mount", "{text}");
+        assert!(text.contains(".git"), "{text}");
+
+        std::fs::remove_file(f.root.join(".git")).expect("rm link");
+        symlink(&elsewhere, f.root.join(".zed")).expect("symlink .zed");
+        let (text, cause) = refusal(run(&project, plus(), cx).await);
+        assert_eq!(cause, "symlinked_mount", "{text}");
+        assert!(text.contains(".zed"), "{text}");
+
+        std::fs::remove_file(f.root.join(".zed")).expect("rm link");
+        std::fs::write(f.root.join(".zed"), "").expect(".zed file");
+        let (text, cause) = refusal(run(&project, plus(), cx).await);
+        assert_eq!(cause, "zed_dir_create", "{text}");
+    }
+
+    #[gpui::test]
+    async fn launch_never_recreates_a_deleted_root(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        std::fs::remove_dir_all(&f.root).expect("delete root");
+        let (text, cause) = refusal(run(&project, plus(), cx).await);
+        assert_eq!(cause, "root_missing", "{text}");
+        assert!(text.contains(&f.root.display().to_string()), "{text}");
+        assert!(!f.root.exists(), "the root is not recreated");
+    }
+
+    #[gpui::test]
+    async fn launch_matches_a_project_key_through_a_symlink(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        let link = f.base.join("app-link");
+        symlink(&f.root, &link).expect("symlink");
+        let mut settings = sandbox(&f, false, Value::Null, memory_off());
+        settings["agent_sandbox"]["projects"][link.display().to_string()] =
+            json!({ "enabled": true });
+        set_user_settings(settings, cx);
+        let project = project(&f, cx).await;
+        let (record, _) = jailed(run(&project, plus(), cx).await);
+        assert_eq!(
+            record.settings_key.as_deref(),
+            Some(link.to_str().expect("utf-8"))
+        );
+        assert!(
+            !cx.update(|cx| drifted(&record, AgentSandboxSettings::get_global(cx))),
+            "the canonical key is what drift compares"
+        );
+    }
+
+    #[gpui::test]
+    async fn launch_binary_and_memory_drift(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        let (record, _) = jailed(run(&project, plus(), cx).await);
+        let version = semver::Version::new(2, 8, 1);
+        assert_eq!(record.ai_jail_path.as_deref(), Some(f.ai_jail.as_path()));
+        assert!(!binary_drifted(&record, Some(&f.ai_jail), Some(&version)));
+        assert!(binary_drifted(
+            &record,
+            Some(Path::new("/usr/bin/ai-jail")),
+            Some(&version)
+        ));
+        assert!(binary_drifted(
+            &record,
+            Some(&f.ai_jail),
+            Some(&semver::Version::new(2, 9, 0))
+        ));
+        assert!(binary_drifted(&record, None, None), "ai-jail gone");
+
+        assert!(!cx.update(|cx| memory_drifted(&record, AgentMemorySettings::get_global(cx))));
+        set_user_settings(
+            sandbox(&f, true, Value::Null, json!({ "enabled": true })),
+            cx,
+        );
+        assert!(cx.update(|cx| memory_drifted(&record, AgentMemorySettings::get_global(cx))));
+        assert!(cx.update(|cx| crate::launch::drifted_now(&record, cx)));
+    }
+
+    #[gpui::test]
+    async fn launch_refuses_a_root_ai_jail_cannot_map(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        let colon = f.base.join("foo:bar");
+        std::fs::create_dir_all(colon.join(".git")).expect("colon root");
+        let mut settings = sandbox(&f, true, Value::Null, memory_off());
+        settings["agent_sandbox"]["projects"] = json!({});
+        set_user_settings(settings, cx);
+        let fs = RealFs::new(None, cx.executor());
+        let project = Project::test(fs, [colon.as_path()], cx).await;
+        cx.run_until_parked();
+        let (text, cause) = refusal(run(&project, plus(), cx).await);
+        assert_eq!(cause, "root_unusable", "{text}");
+        assert!(text.contains(&colon.display().to_string()), "{text}");
+        assert!(!colon.join(".zed").exists(), "nothing created");
+
+        use std::os::unix::ffi::OsStrExt as _;
+        let not_utf8 = f.base.join(std::ffi::OsStr::from_bytes(b"app-\xff"));
+        std::fs::create_dir_all(&not_utf8).expect("non-UTF-8 root");
+        let set = ProtectedSet::new(&f.base.join("home"), &f.base.join("home/.config/zeo"), None);
+        let cause = check_root(&not_utf8, &set).expect_err("non-UTF-8 root refused");
+        assert_eq!(cause.as_str(), "root_unusable");
+        let cause = check_root(&colon, &set).expect_err("':' root refused");
+        assert_eq!(cause.as_str(), "root_unusable");
+    }
+}
+
+// Group-3 redo review: F1 (ai-jail's own environment), F2 (an adapter behind a shell string),
+// F3 (the jail runs in the root that was checked).
+mod review_round_three {
+    use super::*;
+    use crate::launch::{JailSpawn, LaunchEnv, LaunchPlan, plan_in};
+
+    async fn plan_with(
+        agent: &str,
+        project: &Entity<Project>,
+        cmd: AgentServerCommand,
+        configured: &[&str],
+        env: LaunchEnv,
+        cx: &mut TestAppContext,
+    ) -> LaunchPlan {
+        let configured: std::collections::HashSet<String> =
+            configured.iter().map(|name| name.to_string()).collect();
+        let mut async_cx = cx.to_async();
+        plan_in(
+            AgentId::new(agent),
+            project,
+            cmd,
+            &configured,
+            env,
+            &mut async_cx,
+        )
+        .await
+    }
+
+    fn spawn_of(plan: LaunchPlan) -> (LaunchRecord, AgentServerCommand, JailSpawn) {
+        let spawn = plan.jail_spawn.clone();
+        let (record, cmd) = jailed(plan.decision);
+        (
+            record,
+            cmd,
+            spawn.expect("a jailed start carries its spawn"),
+        )
+    }
+
+    // F1: ai-jail copies proxies, `LC_*` and `XDG_*` from its own environment into the jail and
+    // finds its configuration through HOME, so its environment is only what was configured plus
+    // a base from Zeo's own process.
+    #[gpui::test]
+    async fn launch_jail_env_is_only_the_configured_names_and_zeos_base(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        let home = f.base.join("zeo-home");
+        let runtime = f.base.join("runtime");
+        let mut cmd = plain_cmd("/usr/bin/claude-agent-acp-plus", &["--acp"]);
+        cmd.env = Some(HashMap::from_iter(
+            [
+                ("HTTPS_PROXY", "http://user:s3cr3t@proxy.example:3128"),
+                ("XDG_FAKE_TOKEN", "xdg-token-value"),
+                ("LC_SECRET", "lc-secret-value"),
+                ("HOME", "/home/from-the-shell"),
+                ("AI_JAIL_FAKE", "steer"),
+                ("BWRAP_BIN", "/tmp/fake-bwrap"),
+                ("HOOK_SECRET", "hook-secret-value"),
+            ]
+            .map(|(name, value)| (name.to_string(), value.to_string())),
+        ));
+        let env = LaunchEnv {
+            home: home.clone(),
+            config_dir: home.join(".config/zeo"),
+            runtime_dir: Some(runtime.clone()),
+        };
+        let (_, cmd, spawn) = spawn_of(
+            plan_with(
+                ZEO_PLUS_AGENT_ID,
+                &project,
+                cmd,
+                &["HOOK_SECRET", "HOME"],
+                env,
+                cx,
+            )
+            .await,
+        );
+        let names: Vec<&str> = spawn.env.iter().map(|(name, _)| name.as_str()).collect();
+        for name in &names {
+            assert!(
+                ["HOOK_SECRET", "HOME", "PATH", "XDG_RUNTIME_DIR", "LANG"].contains(name),
+                "{name} reached ai-jail: {names:?}"
+            );
+        }
+        let value = |name: &str| {
+            spawn
+                .env
+                .iter()
+                .find(|(key, _)| key == name)
+                .map(|(_, value)| value.clone())
+        };
+        assert_eq!(value("HOOK_SECRET").as_deref(), Some("hook-secret-value"));
+        assert_eq!(
+            value("HOME"),
+            Some(home.display().to_string()),
+            "HOME is Zeo's, even when the agent's settings set one"
+        );
+        assert_eq!(
+            value("XDG_RUNTIME_DIR"),
+            Some(runtime.display().to_string())
+        );
+        assert_eq!(value("PATH"), std::env::var("PATH").ok(), "Zeo's own PATH");
+        assert!(
+            spawn.env.iter().all(|(_, value)| !value.contains("s3cr3t")),
+            "{:?}",
+            spawn.env
+        );
+        assert_eq!(count(&cmd.args, "--env", "HOOK_SECRET"), 1);
+        assert_eq!(count(&cmd.args, "--env", "HTTPS_PROXY"), 0);
+    }
+
+    // F3: `check_root` approves the resolved root; the jail's cwd and its read-only maps must be
+    // that path, not the spelling a symlink could re-point after the check.
+    #[gpui::test]
+    async fn launch_jail_runs_in_the_resolved_root(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        let link = f.base.join("app-link");
+        symlink(&f.root, &link).expect("symlink");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = Project::test(RealFs::new(None, cx.executor()), [link.as_path()], cx).await;
+        cx.run_until_parked();
+        let spelled = cx
+            .update(|cx| crate::launch::project_root(&project, cx))
+            .expect("a root");
+        let env = LaunchEnv {
+            home: f.base.join("zeo-home"),
+            config_dir: f.base.join("zeo-home/.config/zeo"),
+            runtime_dir: None,
+        };
+        let (record, cmd, spawn) = spawn_of(
+            plan_with(
+                ZEO_PLUS_AGENT_ID,
+                &project,
+                plain_cmd("/usr/bin/claude-agent-acp-plus", &["--acp"]),
+                &[],
+                env,
+                cx,
+            )
+            .await,
+        );
+        assert_eq!(spawn.cwd, f.root, "the jail runs in the resolved root");
+        let git = f.root.join(".git").display().to_string();
+        assert_eq!(count(&cmd.args, "--map", &git), 1, "{:?}", cmd.args);
+        let spelled_git = spelled.join(".git").display().to_string();
+        if spelled != f.root {
+            assert_eq!(count(&cmd.args, "--map", &spelled_git), 0, "{:?}", cmd.args);
+        }
+        assert_eq!(
+            record.project_root, spelled,
+            "the record keeps the project's spelling for the registry and the UI"
+        );
+    }
+
+    // F2: a custom entry that starts the adapter through a shell string is the adapter.
+    #[gpui::test]
+    async fn launch_recognises_an_adapter_started_through_a_shell_string(cx: &mut TestAppContext) {
+        init(cx);
+        let f = fixture("ai-jail 2.8.1");
+        set_user_settings(sandbox(&f, true, Value::Null, memory_off()), cx);
+        let project = project(&f, cx).await;
+        for (shell, script) in [
+            ("bash", "npx -y @lucascouts/claude-agent-acp-plus"),
+            ("bash", "npx -y '@lucascouts/claude-agent-tui@1.2.3' --acp"),
+            ("sh", "exec claude-agent-acp-plus --acp"),
+            ("sh", "cd /tmp && /usr/bin/claude-agent-acp-tui"),
+            ("sh", "exec claude-agent-acp-plus;"),
+        ] {
+            let cmd = plain_cmd(&format!("/bin/{shell}"), &["-lc", script]);
+            let (record, cmd) = jailed(run_with("my-shell-agent", &project, cmd, &[], cx).await);
+            assert_eq!(record.reason, Reason::On, "{script}");
+            assert_eq!(cmd.path, f.ai_jail, "{script} is wrapped");
+        }
+        for script in [
+            "my-claude-agent-acp-plus --acp",
+            "npx -y @lucascouts/claude-agent-acp-plus-fork",
+            "exec claude-agent-acp-plus2",
+            "npx -y @someone/claude-agent-acp-plusplus",
+        ] {
+            let cmd = plain_cmd("/bin/bash", &["-lc", script]);
+            let record = unchanged(run_with("my-shell-agent", &project, cmd, &[], cx).await);
+            assert_eq!(record.reason, Reason::NotClaude, "{script} is a look-alike");
+        }
+    }
+}
+
+// Fix round of 10.3 (live proof, defect #1): with filtered egress ai-jail exports its own proxy and
+// forces `no_proxy`/`NO_PROXY` empty AFTER every `--env` (measured on the fork 2.7.0), so a client
+// honouring the proxy variables sends `http://127.0.0.1:<memory port>` to ai-jail's proxy (HTTP
+// 405) instead of the forwarded port. A forwarded memory port therefore carries the exclusion in
+// `ZEO_NO_PROXY` (loopback merged with the configured values, never on argv) and restores it inside
+// the jail, after ai-jail's forced values, in front of the agent.
+//
+// Hostile halves first:
+//   - loopback, wrongly PROXIED: memory forwarded → the spawn env carries `ZEO_NO_PROXY` with
+//     127.0.0.1, localhost and ::1, forwarded once by name, and the agent runs behind the wrapper
+//     that exports it as `no_proxy` and `NO_PROXY`.
+//   - user entries, wrongly DROPPED: a configured `no_proxy`/`NO_PROXY` is merged in, in order,
+//     without duplicates, and its value never reaches argv.
+//   - wrapper, wrongly ADDED: memory off, or an ai-jail without `--forward-port`, leaves the agent
+//     right after `--` and adds no `ZEO_NO_PROXY`.
+mod loopback_off_the_jail_proxy {
+    use super::*;
+    use crate::launch::{JailSpawn, LaunchEnv, LaunchPlan, plan_in};
+
+    const AGENT: &str = "/usr/bin/claude-agent-acp-plus";
+
+    fn forward_port_fixture() -> Fixture {
+        let f = fixture("ai-jail 2.8.1");
+        write_script(
+            &f.ai_jail,
+            "case \"$1\" in --help) printf '    --forward-port <PORT>  Forward a host port\\n' ;; *) echo 'ai-jail 2.8.1' ;; esac",
+        );
+        f
+    }
+
+    async fn plan_for(
+        f: &Fixture,
+        memory: Value,
+        cmd: AgentServerCommand,
+        configured: &[&str],
+        cx: &mut TestAppContext,
+    ) -> (AgentServerCommand, JailSpawn) {
+        set_user_settings(sandbox(f, true, Value::Null, memory), cx);
+        let project = project(f, cx).await;
+        let configured: std::collections::HashSet<String> =
+            configured.iter().map(|name| name.to_string()).collect();
+        let env = LaunchEnv {
+            home: f.base.join("zeo-home"),
+            config_dir: f.base.join("zeo-home/.config/zeo"),
+            runtime_dir: None,
+        };
+        let mut async_cx = cx.to_async();
+        let plan: LaunchPlan = plan_in(
+            AgentId::new(ZEO_PLUS_AGENT_ID),
+            &project,
+            cmd,
+            &configured,
+            env,
+            &mut async_cx,
+        )
+        .await;
+        let spawn = plan
+            .jail_spawn
+            .clone()
+            .expect("a jailed start carries its spawn");
+        (jailed(plan.decision).1, spawn)
+    }
+
+    fn memory_on() -> Value {
+        json!({ "enabled": true, "endpoint": "http://127.0.0.1:49374/mcp" })
+    }
+
+    fn after_separator(args: &[String]) -> &[String] {
+        let at = args
+            .iter()
+            .position(|arg| arg == "--")
+            .expect("a `--` before the agent");
+        &args[at + 1..]
+    }
+
+    fn value<'a>(spawn: &'a JailSpawn, name: &str) -> Option<&'a str> {
+        spawn
+            .env
+            .iter()
+            .find(|(key, _)| key == name)
+            .map(|(_, value)| value.as_str())
+    }
+
+    #[gpui::test]
+    async fn launch_memory_forwarded_keeps_loopback_off_the_jail_proxy(cx: &mut TestAppContext) {
+        init(cx);
+        let f = forward_port_fixture();
+        let mut cmd = plain_cmd(AGENT, &["--acp"]);
+        cmd.env = Some(HashMap::from_iter(
+            [
+                ("no_proxy", "corp.example, localhost,,"),
+                ("NO_PROXY", "corp.example,internal.example"),
+            ]
+            .map(|(name, value)| (name.to_string(), value.to_string())),
+        ));
+        let (cmd, spawn) = plan_for(&f, memory_on(), cmd, &["no_proxy", "NO_PROXY"], cx).await;
+
+        assert_eq!(
+            value(&spawn, "ZEO_NO_PROXY"),
+            Some("127.0.0.1,localhost,::1,corp.example,internal.example"),
+            "loopback first, then the configured entries, deduplicated: {:?}",
+            spawn.env
+        );
+        assert_eq!(
+            count(&cmd.args, "--forward-port", "49374"),
+            1,
+            "{:?}",
+            cmd.args
+        );
+        assert_eq!(
+            count(&cmd.args, "--env", "ZEO_NO_PROXY"),
+            1,
+            "{:?}",
+            cmd.args
+        );
+        assert!(
+            !cmd.args.iter().any(|arg| arg.contains("corp.example")),
+            "a configured value never reaches argv: {:?}",
+            cmd.args
+        );
+        let agent = after_separator(&cmd.args);
+        assert_eq!(
+            &agent[..2],
+            &["/bin/sh".to_string(), "-c".to_string()],
+            "{agent:?}"
+        );
+        let script = &agent[2];
+        for name in [
+            "no_proxy=",
+            "NO_PROXY=",
+            "ZEO_NO_PROXY",
+            "127.0.0.1,localhost,::1",
+        ] {
+            assert!(
+                script.contains(name),
+                "{name} missing from the wrapper: {script}"
+            );
+        }
+        assert!(
+            script.ends_with("exec \"$@\""),
+            "the agent replaces the shell: {script}"
+        );
+        assert_eq!(
+            &agent[3..],
+            &["sh".to_string(), AGENT.to_string(), "--acp".to_string()],
+            "the agent and its arguments follow unchanged: {agent:?}"
+        );
+    }
+
+    #[gpui::test]
+    async fn launch_memory_forwarded_without_a_configured_value_is_loopback_only(
+        cx: &mut TestAppContext,
+    ) {
+        init(cx);
+        let f = forward_port_fixture();
+        let (cmd, spawn) = plan_for(&f, memory_on(), plain_cmd(AGENT, &["--acp"]), &[], cx).await;
+        assert_eq!(
+            value(&spawn, "ZEO_NO_PROXY"),
+            Some("127.0.0.1,localhost,::1")
+        );
+        assert_eq!(
+            count(&cmd.args, "--env", "ZEO_NO_PROXY"),
+            1,
+            "{:?}",
+            cmd.args
+        );
+    }
+
+    #[gpui::test]
+    async fn launch_memory_not_forwarded_adds_no_proxy_exclusion(cx: &mut TestAppContext) {
+        init(cx);
+        // Memory off with a binary that offers --forward-port, then memory on with one that does
+        // not (Unsupported): neither forwards a port, so neither wraps the agent.
+        for (fixture, memory) in [
+            (forward_port_fixture(), memory_off()),
+            (fixture("ai-jail 2.8.1"), memory_on()),
+        ] {
+            let (cmd, spawn) =
+                plan_for(&fixture, memory, plain_cmd(AGENT, &["--acp"]), &[], cx).await;
+            assert_eq!(value(&spawn, "ZEO_NO_PROXY"), None, "{:?}", spawn.env);
+            assert_eq!(
+                count(&cmd.args, "--env", "ZEO_NO_PROXY"),
+                0,
+                "{:?}",
+                cmd.args
+            );
+            assert_eq!(
+                after_separator(&cmd.args),
+                &[AGENT.to_string(), "--acp".to_string()],
+                "the agent sits right after `--`: {:?}",
+                cmd.args
+            );
+        }
+    }
+}
diff --git a/crates/agent_sandbox/src/memory.rs b/crates/agent_sandbox/src/memory.rs
new file mode 100644
index 0000000..99690dd
--- /dev/null
+++ b/crates/agent_sandbox/src/memory.rs
@@ -0,0 +1,15 @@
+//! The ai-memory integration: which memory scope a project belongs to (R6.7), and the MCP
+//! client for its status, recent pages and page deletion (R6).
+
+mod client;
+mod scope;
+
+pub use client::{
+    MemoryClient, MemoryError, MemoryStatus, REQUEST_TIMEOUT, RecentPage, display_endpoint,
+    forward_port,
+};
+
+pub use scope::{
+    DEFAULT_WORKSPACE, MARKER_FILE_NAME, MemoryScope, ResolvedBy, project_from_remote_url,
+    resolve_project_scope, resolve_scope,
+};
diff --git a/crates/agent_sandbox/src/memory/client.rs b/crates/agent_sandbox/src/memory/client.rs
new file mode 100644
index 0000000..4f9368f
--- /dev/null
+++ b/crates/agent_sandbox/src/memory/client.rs
@@ -0,0 +1,578 @@
+//! A minimal MCP client for ai-memory's three tools (R6.1, R6.5, R6.6, R6.8, R6.9, R9.3, R9.4).
+//!
+//! It speaks JSON-RPC over `context_server`'s `HttpTransport` itself instead of going through
+//! `context_server::Client`: that client trace-logs every message it receives, which here would
+//! put page text in the log (R9.4).
+
+use std::fmt;
+use std::net::{Ipv4Addr, Ipv6Addr};
+use std::pin::Pin;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use context_server::transport::{HttpTransport, Transport as _, TransportError};
+use context_server::types::{self, CallToolResponse, ToolResponseContent};
+use futures::{FutureExt as _, Stream, StreamExt as _};
+use gpui::{AsyncApp, BackgroundExecutor};
+use http_client::HttpClient;
+use serde::Deserialize;
+use serde::de::DeserializeOwned;
+use serde_json::{Value, json};
+use url::{Host, Url};
+
+use super::MemoryScope;
+
+/// How long one call may take, session start included (R6.6).
+pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
+
+const TOOL_STATUS: &str = "memory_status";
+const TOOL_RECENT: &str = "memory_recent";
+const TOOL_DELETE: &str = "memory_delete_page";
+
+const REJECTED_TOKEN: &str = "ai-memory rejected the auth token (HTTP 401)";
+const TOKEN_REQUIRED: &str =
+    "ai-memory requires an auth token (HTTP 401): set AI_MEMORY_AUTH_TOKEN";
+
+/// A server's error text can be a proxy's whole HTML page or a traceback: only its first line
+/// is shown.
+const ERROR_TEXT_MAX_CHARS: usize = 200;
+
+/// What the Memory section shows about the server and the scope (R6.1).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MemoryStatus {
+    pub server_version: String,
+    /// The endpoint without its userinfo, query and fragment.
+    pub endpoint: Url,
+    pub scope: MemoryScope,
+    /// `counts.pages_latest`: the live pages, not every stored version.
+    pub pages: u64,
+    pub sessions: u64,
+}
+
+/// One of the most recently updated pages (R6.3).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RecentPage {
+    pub title: String,
+    pub path: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum MemoryError {
+    /// The connection failed (R6.6).
+    Unreachable { endpoint: String },
+    /// No answer within [`REQUEST_TIMEOUT`] (R6.6).
+    Timeout { endpoint: String },
+    /// ai-memory has no such project in the workspace (R6.8).
+    UnknownProject { scope: MemoryScope },
+    /// The server's own error text (R6.6).
+    Server(String),
+    /// An answer Zeo cannot read. The parse error is not kept: it can quote the page text.
+    Malformed { tool: &'static str },
+}
+
+impl fmt::Display for MemoryError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            MemoryError::Unreachable { endpoint } => {
+                write!(f, "ai-memory is not reachable at {endpoint}")
+            }
+            MemoryError::Timeout { endpoint } => write!(
+                f,
+                "ai-memory is not reachable at {endpoint}: no answer within {} seconds",
+                REQUEST_TIMEOUT.as_secs()
+            ),
+            MemoryError::UnknownProject { scope } => {
+                write!(f, "No memory recorded for {scope} yet.")
+            }
+            MemoryError::Server(text) => f.write_str(text),
+            MemoryError::Malformed { tool } => {
+                write!(f, "ai-memory sent an answer to {tool} that Zeo cannot read")
+            }
+        }
+    }
+}
+
+impl std::error::Error for MemoryError {}
+
+impl MemoryError {
+    fn outcome(&self) -> &'static str {
+        match self {
+            MemoryError::Unreachable { .. } => "unreachable",
+            MemoryError::Timeout { .. } => "timeout",
+            MemoryError::UnknownProject { .. } => "unknown_project",
+            MemoryError::Server(_) => "server_error",
+            MemoryError::Malformed { .. } => "malformed",
+        }
+    }
+}
+
+/// The port to forward into the jail: only for `127.0.0.1`, `::1` and `localhost`, which are
+/// the hosts that mean "this machine" inside the jail too (R7.1, R7.3).
+pub fn forward_port(endpoint: &Url) -> Option<u16> {
+    is_loopback(endpoint)
+        .then(|| endpoint.port_or_known_default())
+        .flatten()
+}
+
+/// The endpoint as it may be shown or logged: without its userinfo, query and fragment, which
+/// can each carry a credential.
+pub fn display_endpoint(endpoint: &Url) -> String {
+    shown_endpoint(endpoint).to_string()
+}
+
+fn is_loopback(endpoint: &Url) -> bool {
+    match endpoint.host() {
+        Some(Host::Ipv4(address)) => address == Ipv4Addr::LOCALHOST,
+        Some(Host::Ipv6(address)) => address == Ipv6Addr::LOCALHOST,
+        Some(Host::Domain(domain)) => domain == "localhost",
+        None => false,
+    }
+}
+
+fn shown_endpoint(endpoint: &Url) -> Url {
+    let mut stripped = endpoint.clone();
+    stripped.set_query(None);
+    stripped.set_fragment(None);
+    // Both fail only for a URL without a host, which carries no userinfo either.
+    stripped.set_password(None).ok();
+    stripped.set_username("").ok();
+    stripped
+}
+
+/// The static request headers. `Debug` prints names only: the values carry the token.
+#[derive(Clone, Default)]
+struct RequestHeaders(Vec<(String, String)>);
+
+impl fmt::Debug for RequestHeaders {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_map()
+            .entries(self.0.iter().map(|(name, _)| (name, "<redacted>")))
+            .finish()
+    }
+}
+
+/// A client for one ai-memory endpoint. The MCP session starts on the first call and is dropped
+/// after a timeout or a transport failure, so the next call (a Refresh) starts a fresh one.
+pub struct MemoryClient {
+    endpoint: Url,
+    display_endpoint: String,
+    headers: RequestHeaders,
+    has_token: bool,
+    http_client: Arc<dyn HttpClient>,
+    executor: BackgroundExecutor,
+    session: futures::lock::Mutex<Option<Session>>,
+    next_id: AtomicU64,
+}
+
+impl fmt::Debug for MemoryClient {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("MemoryClient")
+            .field("endpoint", &self.display_endpoint)
+            .finish_non_exhaustive()
+    }
+}
+
+struct Session {
+    transport: HttpTransport,
+    responses: Pin<Box<dyn Stream<Item = String> + Send>>,
+    errors: Pin<Box<dyn Stream<Item = String> + Send>>,
+    server_version: String,
+}
+
+/// A failed call, and whether the session it ran on is still usable.
+struct Failure {
+    error: MemoryError,
+    drop_session: bool,
+}
+
+impl Failure {
+    fn keep(error: MemoryError) -> Self {
+        Self {
+            error,
+            drop_session: false,
+        }
+    }
+
+    fn drop_session(error: MemoryError) -> Self {
+        Self {
+            error,
+            drop_session: true,
+        }
+    }
+}
+
+#[derive(Deserialize)]
+struct RpcFrame {
+    id: Option<Value>,
+    method: Option<String>,
+    result: Option<Value>,
+    error: Option<RpcErrorBody>,
+}
+
+#[derive(Deserialize)]
+struct RpcErrorBody {
+    #[serde(default)]
+    message: String,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct InitializeAnswer {
+    protocol_version: String,
+    server_info: ServerInfo,
+}
+
+#[derive(Deserialize)]
+struct ServerInfo {
+    version: String,
+}
+
+#[derive(Deserialize)]
+struct StatusAnswer {
+    counts: StatusCounts,
+}
+
+#[derive(Deserialize)]
+struct StatusCounts {
+    pages_latest: u64,
+    sessions: u64,
+}
+
+#[derive(Deserialize)]
+struct RecentAnswer {
+    hits: Vec<RecentHit>,
+}
+
+#[derive(Deserialize)]
+struct RecentHit {
+    title: String,
+    path: String,
+}
+
+impl MemoryClient {
+    /// `auth_token` is the caller's `AI_MEMORY_AUTH_TOKEN`; it is sent as a bearer token and
+    /// never shown or logged (R6.9, R9.4).
+    pub fn new(
+        endpoint: Url,
+        auth_token: Option<String>,
+        http_client: Arc<dyn HttpClient>,
+        cx: &AsyncApp,
+    ) -> anyhow::Result<Self> {
+        let display_endpoint = display_endpoint(&endpoint);
+        if auth_token.is_some() && endpoint.scheme() == "http" && !is_loopback(&endpoint) {
+            anyhow::bail!(
+                "refusing to send the ai-memory auth token in clear text to {display_endpoint}: \
+                 use https or a loopback endpoint"
+            );
+        }
+        let has_token = auth_token.is_some();
+        let mut headers = RequestHeaders::default();
+        if let Some(token) = auth_token {
+            headers
+                .0
+                .push(("Authorization".to_string(), format!("Bearer {token}")));
+        }
+        Ok(Self {
+            endpoint,
+            display_endpoint,
+            headers,
+            has_token,
+            http_client,
+            executor: cx.background_executor().clone(),
+            session: futures::lock::Mutex::new(None),
+            next_id: AtomicU64::new(1),
+        })
+    }
+
+    /// The server version, the endpoint and the scope's page and session counts (R6.1).
+    pub async fn status(&self, scope: &MemoryScope) -> Result<MemoryStatus, MemoryError> {
+        let arguments = json!({ "workspace": scope.workspace, "project": scope.project });
+        let (answer, server_version) = self
+            .call::<StatusAnswer>(TOOL_STATUS, arguments, scope)
+            .await?;
+        Ok(MemoryStatus {
+            server_version,
+            endpoint: shown_endpoint(&self.endpoint),
+            scope: scope.clone(),
+            pages: answer.counts.pages_latest,
+            sessions: answer.counts.sessions,
+        })
+    }
+
+    /// The scope's most recently updated pages, in the server's order (R6.3).
+    pub async fn recent(
+        &self,
+        scope: &MemoryScope,
+        limit: usize,
+    ) -> Result<Vec<RecentPage>, MemoryError> {
+        let arguments =
+            json!({ "workspace": scope.workspace, "project": scope.project, "limit": limit });
+        let (answer, _) = self
+            .call::<RecentAnswer>(TOOL_RECENT, arguments, scope)
+            .await?;
+        Ok(answer
+            .hits
+            .into_iter()
+            .map(|hit| RecentPage {
+                title: hit.title,
+                path: hit.path,
+            })
+            .collect())
+    }
+
+    /// Deletes exactly `path` in `scope` (R6.5).
+    pub async fn delete(&self, scope: &MemoryScope, path: &str) -> Result<(), MemoryError> {
+        let arguments =
+            json!({ "path": path, "workspace": scope.workspace, "project": scope.project });
+        self.call::<Value>(TOOL_DELETE, arguments, scope).await?;
+        Ok(())
+    }
+
+    /// One tool call under one [`REQUEST_TIMEOUT`] timer, session start included, logged as one
+    /// line that carries no argument, answer or token (R9.3, R9.4).
+    async fn call<T: DeserializeOwned>(
+        &self,
+        tool: &'static str,
+        arguments: Value,
+        scope: &MemoryScope,
+    ) -> Result<(T, String), MemoryError> {
+        let started = self.executor.now();
+        let timeout = self.executor.timer(REQUEST_TIMEOUT);
+        let result = futures::select_biased! {
+            result = self.call_in_session(tool, arguments, scope).fuse() => result,
+            _ = timeout.fuse() => Err(Failure::drop_session(MemoryError::Timeout {
+                endpoint: self.display_endpoint.clone(),
+            })),
+        };
+        let elapsed_ms = self
+            .executor
+            .now()
+            .saturating_duration_since(started)
+            .as_millis();
+        match result {
+            Ok(answer) => {
+                log::info!("agent_memory event=call tool={tool} outcome=ok ms={elapsed_ms}");
+                Ok(answer)
+            }
+            Err(failure) => {
+                if failure.drop_session {
+                    *self.session.lock().await = None;
+                }
+                log::warn!(
+                    "agent_memory event=call tool={tool} outcome={} ms={elapsed_ms}",
+                    failure.error.outcome()
+                );
+                Err(failure.error)
+            }
+        }
+    }
+
+    async fn call_in_session<T: DeserializeOwned>(
+        &self,
+        tool: &'static str,
+        arguments: Value,
+        scope: &MemoryScope,
+    ) -> Result<(T, String), Failure> {
+        let mut guard = self.session.lock().await;
+        let session = match &mut *guard {
+            Some(session) => session,
+            empty => empty.insert(self.start_session(tool).await?),
+        };
+        let params = json!({ "name": tool, "arguments": arguments });
+        let result = match self.request(session, "tools/call", params, tool).await? {
+            Ok(result) => result,
+            Err(message) => return Err(Failure::keep(self.tool_rpc_error(message, scope))),
+        };
+        let answer = parse_tool_result(tool, result)?;
+        Ok((answer, session.server_version.clone()))
+    }
+
+    async fn start_session(&self, tool: &'static str) -> Result<Session, Failure> {
+        let transport = HttpTransport::new(
+            self.http_client.clone(),
+            self.endpoint.to_string(),
+            self.headers.0.iter().cloned().collect(),
+            self.executor.clone(),
+        );
+        let mut session = Session {
+            responses: transport.receive(),
+            errors: transport.receive_err(),
+            transport,
+            server_version: String::new(),
+        };
+        let params = json!({
+            "protocolVersion": types::LATEST_PROTOCOL_VERSION,
+            "capabilities": {},
+            "clientInfo": { "name": "zeo-agent-memory", "version": env!("CARGO_PKG_VERSION") },
+        });
+        let result = match self
+            .request(&mut session, "initialize", params, tool)
+            .await?
+        {
+            Ok(result) => result,
+            Err(message) => {
+                return Err(Failure::drop_session(MemoryError::Server(bounded(
+                    &message,
+                ))));
+            }
+        };
+        let answer: InitializeAnswer = serde_json::from_value(result)
+            .map_err(|_| Failure::drop_session(MemoryError::Malformed { tool }))?;
+        session
+            .transport
+            .set_protocol_version(&answer.protocol_version);
+        session.server_version = answer.server_info.version;
+        let initialized = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
+        session
+            .transport
+            .send(initialized.to_string())
+            .await
+            .map_err(|error| self.transport_failure(error))?;
+        // The transport queues an HTTP error on a notification instead of returning it; left
+        // there, it would be blamed on the first tool call.
+        if let Some(Some(error)) = session.errors.next().now_or_never() {
+            return Err(self.http_failure(&error));
+        }
+        Ok(session)
+    }
+
+    /// Sends one request and waits for the answer with its id. `Ok(Err(message))` is a JSON-RPC
+    /// error from the server.
+    async fn request(
+        &self,
+        session: &mut Session,
+        method: &str,
+        params: Value,
+        tool: &'static str,
+    ) -> Result<Result<Value, String>, Failure> {
+        drop_stale_errors(session);
+        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
+        let message = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
+        session
+            .transport
+            .send(message.to_string())
+            .await
+            .map_err(|error| self.transport_failure(error))?;
+        loop {
+            futures::select_biased! {
+                frame = session.responses.next().fuse() => {
+                    let Some(frame) = frame else {
+                        return Err(Failure::drop_session(MemoryError::Unreachable {
+                            endpoint: self.display_endpoint.clone(),
+                        }));
+                    };
+                    let frame: RpcFrame = serde_json::from_str(&frame)
+                        .map_err(|_| Failure::keep(MemoryError::Malformed { tool }))?;
+                    if frame.method.is_some() || frame.id != Some(Value::from(id)) {
+                        continue;
+                    }
+                    if let Some(error) = frame.error {
+                        return Ok(Err(error.message));
+                    }
+                    return frame
+                        .result
+                        .map(Ok)
+                        .ok_or(Failure::keep(MemoryError::Malformed { tool }));
+                }
+                error = session.errors.next().fuse() => {
+                    let Some(error) = error else {
+                        return Err(Failure::drop_session(MemoryError::Unreachable {
+                            endpoint: self.display_endpoint.clone(),
+                        }));
+                    };
+                    return Err(self.http_failure(&error));
+                }
+            }
+        }
+    }
+
+    fn transport_failure(&self, error: anyhow::Error) -> Failure {
+        if let Some(TransportError::AuthRequired { .. }) = error.downcast_ref::<TransportError>() {
+            return Failure::drop_session(self.unauthorized());
+        }
+        Failure::drop_session(MemoryError::Unreachable {
+            endpoint: self.display_endpoint.clone(),
+        })
+    }
+
+    fn tool_rpc_error(&self, message: String, scope: &MemoryScope) -> MemoryError {
+        // ai-memory's text is "project '<p>' not found in workspace '<w>'"; a page or a workspace
+        // that is not found is a server error, not "no memory recorded" (R6.8).
+        if message.starts_with("project '") && message.contains("not found in workspace") {
+            MemoryError::UnknownProject {
+                scope: scope.clone(),
+            }
+        } else {
+            MemoryError::Server(bounded(&message))
+        }
+    }
+
+    /// A 401 blames the token only when one was sent.
+    fn unauthorized(&self) -> MemoryError {
+        MemoryError::Server(
+            if self.has_token {
+                REJECTED_TOKEN
+            } else {
+                TOKEN_REQUIRED
+            }
+            .to_string(),
+        )
+    }
+
+    /// The transport's id-less `HTTP <status>: <body>` error. The session is dropped.
+    fn http_failure(&self, error: &str) -> Failure {
+        let Some((status, body)) = error
+            .strip_prefix("HTTP ")
+            .and_then(|rest| rest.split_once(": "))
+        else {
+            return Failure::drop_session(MemoryError::Server(bounded(error)));
+        };
+        let code = status.split_whitespace().next().unwrap_or(status);
+        if code == "401" {
+            return Failure::drop_session(self.unauthorized());
+        }
+        Failure::drop_session(MemoryError::Server(bounded(&format!(
+            "HTTP {code}: {body}"
+        ))))
+    }
+}
+
+/// `isError` is the server's text; otherwise `content[0].text` is the tool's JSON answer
+/// (ai-memory sends no `structuredContent`).
+fn parse_tool_result<T: DeserializeOwned>(tool: &'static str, result: Value) -> Result<T, Failure> {
+    let malformed = || Failure::keep(MemoryError::Malformed { tool });
+    let response: CallToolResponse = serde_json::from_value(result).map_err(|_| malformed())?;
+    if response.is_error == Some(true) {
+        return Err(Failure::keep(MemoryError::Server(bounded(
+            &response.text_contents(),
+        ))));
+    }
+    let Some(ToolResponseContent::Text { text }) = response.content.first() else {
+        return Err(malformed());
+    };
+    serde_json::from_str(text).map_err(|_| malformed())
+}
+
+/// Discards transport errors queued before this request: an id-less error cannot be matched to
+/// a request, so one left over would be blamed on the next call.
+fn drop_stale_errors(session: &mut Session) {
+    let mut dropped = 0;
+    while let Some(Some(_)) = session.errors.next().now_or_never() {
+        dropped += 1;
+    }
+    if dropped > 0 {
+        log::debug!("agent_memory event=stale_transport_errors dropped={dropped}");
+    }
+}
+
+/// The first line, at most [`ERROR_TEXT_MAX_CHARS`] characters.
+fn bounded(text: &str) -> String {
+    text.lines()
+        .next()
+        .unwrap_or_default()
+        .trim_end()
+        .chars()
+        .take(ERROR_TEXT_MAX_CHARS)
+        .collect()
+}
diff --git a/crates/agent_sandbox/src/memory/scope.rs b/crates/agent_sandbox/src/memory/scope.rs
new file mode 100644
index 0000000..999ac25
--- /dev/null
+++ b/crates/agent_sandbox/src/memory/scope.rs
@@ -0,0 +1,292 @@
+//! Resolves the ai-memory scope (workspace and project) of a project root (R6.7).
+//!
+//! The order mirrors ai-memory's own: the nearest valid `.ai-memory.toml` at or above the root,
+//! then the `upstream` remote, then `origin`, then the root folder's name. The workspace is
+//! [`DEFAULT_WORKSPACE`] unless a marker names one.
+//!
+//! Known limit of the rule: replacing `/` with `-` makes `a-b/c` and `a/b-c` one scope. A marker
+//! disambiguates them.
+
+use std::fmt;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use gpui::{App, Entity, Task};
+use project::{Fs, Project};
+
+/// The marker file ai-memory reads, looked up at and above the project root.
+pub const MARKER_FILE_NAME: &str = ".ai-memory.toml";
+
+/// The workspace used when no marker names one.
+pub const DEFAULT_WORKSPACE: &str = "default";
+
+/// A marker larger than this is not a marker anybody wrote by hand; it is skipped unread.
+const MARKER_MAX_BYTES: u64 = 64 * 1024;
+
+/// The ai-memory scope of a project.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MemoryScope {
+    pub workspace: String,
+    pub project: String,
+    /// Where the project name came from.
+    pub resolved_by: ResolvedBy,
+}
+
+impl fmt::Display for MemoryScope {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}/{}", self.workspace, self.project)
+    }
+}
+
+/// The source of a [`MemoryScope`]'s project name.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ResolvedBy {
+    /// The `project` key of the nearest valid `.ai-memory.toml`.
+    Marker,
+    /// The `upstream` remote of the nearest git repository.
+    Upstream,
+    /// The `origin` remote of the nearest git repository.
+    Origin,
+    /// The root folder's name.
+    Folder,
+}
+
+impl ResolvedBy {
+    fn as_str(self) -> &'static str {
+        match self {
+            ResolvedBy::Marker => "marker",
+            ResolvedBy::Upstream => "upstream",
+            ResolvedBy::Origin => "origin",
+            ResolvedBy::Folder => "folder",
+        }
+    }
+}
+
+/// [`resolve_scope`] with the project's `Fs`, on the background executor.
+pub fn resolve_project_scope(
+    root: &Path,
+    project: &Entity<Project>,
+    cx: &App,
+) -> Task<MemoryScope> {
+    let fs = project.read(cx).fs().clone();
+    let root = root.to_path_buf();
+    cx.background_executor()
+        .spawn(async move { resolve_scope(&root, fs).await })
+}
+
+/// Resolves the scope of `root` (R6.7). Never fails: every unreadable source falls through to
+/// the next one, ending at the folder name.
+///
+/// The repository is opened untrusted (Zeo's git layer then runs `git` with hooks, the ssh
+/// command and credential helpers disabled), and nothing is spawned here other than through it.
+pub async fn resolve_scope(root: &Path, fs: Arc<dyn Fs>) -> MemoryScope {
+    let marker = find_marker(root, fs.as_ref()).await;
+    let workspace = marker
+        .as_ref()
+        .and_then(|marker| marker.workspace.clone())
+        .unwrap_or_else(|| DEFAULT_WORKSPACE.to_string());
+
+    let (project, resolved_by) = match marker.and_then(|marker| marker.project) {
+        Some(project) => (project, ResolvedBy::Marker),
+        None => match project_from_remotes(root, &fs).await {
+            Some(found) => found,
+            None => (folder_name(root), ResolvedBy::Folder),
+        },
+    };
+
+    log::debug!(
+        "agent_memory event=scope_resolved workspace={workspace:?} project={project:?} by={}",
+        resolved_by.as_str()
+    );
+    MemoryScope {
+        workspace,
+        project,
+        resolved_by,
+    }
+}
+
+/// Derives ai-memory's project name from a git remote URL: the repository path without its
+/// host, with the query, fragment, surrounding slashes and a trailing `.git` dropped and `/`
+/// replaced by `-`. `git@host:a/b.git`, `https://host/a/b`, `ssh://git@host:22/a/b.git` all give
+/// `a-b`. A local-path remote (`/srv/x.git`, `../x`) gives `None`; `file:///srv/x.git` gives
+/// `srv-x`.
+pub fn project_from_remote_url(url: &str) -> Option<String> {
+    let url = url.trim();
+    let path = match url.split_once("://") {
+        Some((scheme, rest)) => {
+            let valid_scheme = !scheme.is_empty()
+                && scheme
+                    .chars()
+                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
+            if !valid_scheme {
+                return None;
+            }
+            // Everything up to the first `/` is the authority: userinfo, host and port.
+            rest.split_once('/').map_or("", |(_, path)| path)
+        }
+        None => {
+            // scp-like syntax: a `:` with no `/` before it. Anything else is a local path.
+            let (host, path) = url.split_once(':')?;
+            if host.is_empty() || host.contains('/') {
+                return None;
+            }
+            path
+        }
+    };
+
+    let path = path.split(['?', '#']).next().unwrap_or_default();
+    let mut segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
+    if let Some(last) = segments.last_mut()
+        && let Some(stripped) = last.strip_suffix(".git")
+        && !stripped.is_empty()
+    {
+        *last = stripped;
+    }
+    if segments.is_empty() {
+        None
+    } else {
+        Some(segments.join("-"))
+    }
+}
+
+/// The keys of a valid marker. Either may be absent.
+struct Marker {
+    workspace: Option<String>,
+    project: Option<String>,
+}
+
+/// The nearest valid marker at or above `root`. An invalid one is logged and skipped, so the
+/// search continues upwards; a marker in a sibling or below the root is never looked at.
+async fn find_marker(root: &Path, fs: &dyn Fs) -> Option<Marker> {
+    for dir in root.ancestors() {
+        let path = dir.join(MARKER_FILE_NAME);
+        let Ok(Some(metadata)) = fs.metadata(&path).await else {
+            continue;
+        };
+        if metadata.is_dir {
+            continue;
+        }
+        let text = match read_marker(&path, fs).await {
+            Ok(text) => text,
+            Err(reason) => {
+                log_invalid_marker(&path, reason);
+                continue;
+            }
+        };
+        match parse_marker(&text) {
+            Ok(marker) => return Some(marker),
+            Err(reason) => log_invalid_marker(&path, reason),
+        }
+    }
+    None
+}
+
+/// Reads a marker, or returns the reason code it is not read. Only a regular file is opened
+/// (after following symlinks), so a marker that is a device (`-> /dev/zero`) or a FIFO, which a
+/// hostile clone can ship, is never read and never blocks; the read is also bounded, so a file
+/// swapped after the check cannot grow without limit either.
+async fn read_marker(path: &Path, fs: &dyn Fs) -> Result<String, &'static str> {
+    if fs.is_fake() {
+        // The fake file system has no devices or FIFOs.
+        return fs.load(path).await.map_err(|_| "read_error");
+    }
+    let path = path.to_path_buf();
+    smol::unblock(move || read_regular_file_bounded(&path)).await
+}
+
+fn read_regular_file_bounded(path: &Path) -> Result<String, &'static str> {
+    use std::io::Read as _;
+
+    let is_regular = |metadata: std::fs::Metadata| metadata.file_type().is_file();
+    if !std::fs::metadata(path).is_ok_and(is_regular) {
+        return Err("not_a_regular_file");
+    }
+    let file = std::fs::File::open(path).map_err(|_| "read_error")?;
+    // Checked again on the opened descriptor: the path may have been swapped since.
+    if !file.metadata().is_ok_and(is_regular) {
+        return Err("not_a_regular_file");
+    }
+    let mut bytes = Vec::new();
+    file.take(MARKER_MAX_BYTES + 1)
+        .read_to_end(&mut bytes)
+        .map_err(|_| "read_error")?;
+    if bytes.len() as u64 > MARKER_MAX_BYTES {
+        return Err("too_large");
+    }
+    String::from_utf8(bytes).map_err(|_| "parse_error")
+}
+
+/// Parses a marker, or returns the reason code it is invalid. The `toml` error is dropped on
+/// purpose: it quotes the offending line of the file.
+fn parse_marker(text: &str) -> Result<Marker, &'static str> {
+    let table = text.parse::<toml::Table>().map_err(|_| "parse_error")?;
+    Ok(Marker {
+        workspace: name_key(&table, "workspace").map_err(|()| "workspace_not_a_name")?,
+        project: name_key(&table, "project").map_err(|()| "project_not_a_name")?,
+    })
+}
+
+/// An absent key is `Ok(None)`; a key that is not a usable name is `Err`.
+fn name_key(table: &toml::Table, key: &str) -> Result<Option<String>, ()> {
+    match table.get(key) {
+        None => Ok(None),
+        Some(toml::Value::String(name)) if is_name(name) => Ok(Some(name.clone())),
+        Some(_) => Err(()),
+    }
+}
+
+/// A name is non-empty, carries no control character and no surrounding whitespace, so the
+/// scope shown before a Forget is exactly the one the server receives.
+fn is_name(name: &str) -> bool {
+    !name.is_empty() && name.trim() == name && !name.chars().any(char::is_control)
+}
+
+fn log_invalid_marker(path: &Path, reason: &str) {
+    log::warn!("agent_memory event=marker_invalid file={path:?} reason={reason}");
+}
+
+/// The project name from `upstream`, then `origin`, of the nearest repository at or above
+/// `root`. A remote that is a local path does not count.
+async fn project_from_remotes(root: &Path, fs: &Arc<dyn Fs>) -> Option<(String, ResolvedBy)> {
+    let dot_git = nearest_dot_git(root, fs.as_ref()).await?;
+    let git_binary = which::which("git").ok();
+    let repository = match fs.open_repo(&dot_git, git_binary.as_deref()) {
+        Ok(repository) => repository,
+        Err(error) => {
+            let error = format!("{error:#}");
+            let error = error.lines().next().unwrap_or_default();
+            log::warn!(
+                "agent_memory event=repository_unreadable dot_git={dot_git:?} error={error:?}"
+            );
+            return None;
+        }
+    };
+    let remotes = repository.remote_urls().await;
+    [
+        ("upstream", ResolvedBy::Upstream),
+        ("origin", ResolvedBy::Origin),
+    ]
+    .into_iter()
+    .find_map(|(name, resolved_by)| {
+        let project = project_from_remote_url(remotes.get(name)?)?;
+        Some((project, resolved_by))
+    })
+}
+
+/// The nearest `.git` at or above `root`; a `.git` file (a worktree or a submodule) counts.
+async fn nearest_dot_git(root: &Path, fs: &dyn Fs) -> Option<PathBuf> {
+    for dir in root.ancestors() {
+        let dot_git = dir.join(".git");
+        if let Ok(Some(_)) = fs.metadata(&dot_git).await {
+            return Some(dot_git);
+        }
+    }
+    None
+}
+
+/// The root folder's name; a root without one (`/`) falls back to [`DEFAULT_WORKSPACE`].
+fn folder_name(root: &Path) -> String {
+    root.file_name()
+        .map(|name| name.to_string_lossy().into_owned())
+        .unwrap_or_else(|| DEFAULT_WORKSPACE.to_string())
+}
diff --git a/crates/agent_sandbox/src/memory_tests.rs b/crates/agent_sandbox/src/memory_tests.rs
new file mode 100644
index 0000000..624400c
--- /dev/null
+++ b/crates/agent_sandbox/src/memory_tests.rs
@@ -0,0 +1,787 @@
+// Story 043, task 6.2 — the ai-memory MCP client, its response parsers against the fixtures
+// captured on 2026-10-10, errors, auth and logs (R6.1, R6.5, R6.6, R6.8, R6.9, R7.1, R7.3, R9.3,
+// R9.4; Q4, Q13, Q14). Wired in the crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod memory_tests;
+//
+// Validation filter: `memory` (every test path is `memory_tests::…`).
+//
+// The fake ai-memory is an MCP streamable-HTTP server built on `http_client::FakeHttpClient`
+// (the same seam context_server's own HttpTransport tests use), so the real `ContextServer::http`
+// transport, headers and JSON-RPC framing are exercised.
+//
+// Contract (design.md §agent_sandbox::memory, §Data Models). Assumed signatures — adjust the
+// `use`/constructor only:
+//   - `crate::memory::MemoryClient::new(endpoint: Url, auth_token: Option<String>,
+//      http_client: Arc<dyn HttpClient>, cx: &AsyncApp) -> anyhow::Result<MemoryClient>`
+//     (production passes `std::env::var("AI_MEMORY_AUTH_TOKEN").ok()`)
+//   - `status(&self, &MemoryScope) -> Result<MemoryStatus, MemoryError>`,
+//     `recent(&self, &MemoryScope, limit: usize) -> Result<Vec<RecentPage>, MemoryError>`,
+//     `delete(&self, &MemoryScope, path: &str) -> Result<(), MemoryError>` (all async)
+//   - `MemoryStatus { server_version: String, endpoint, scope: MemoryScope, pages: u64, sessions: u64 }`
+//     — `pages` is `counts.pages_latest` (the live pages; `pages_all` counts every version)
+//   - `RecentPage { path: String, title: String, .. }`
+//   - `MemoryError::{Unreachable { endpoint }, Timeout, UnknownProject { scope }, Server(String), Malformed}`
+//     with the R6.6/R6.8 texts as `Display`
+//   - `crate::memory::forward_port(&Url) -> Option<u16>`
+//
+// Hostile halves first:
+//   - unknown project, wrongly COLLAPSE: the server's *workspace* not-found error (the scope
+//     naming gotcha) is a server error with its text, not "No memory recorded …".
+//   - timeout, both sides: no answer within 5 s is `Timeout`; an answer at 4 s is a result.
+//   - malformed, wrongly PANIC/ACCEPT: non-JSON text, a missing field, empty content.
+//   - loopback, wrongly FORWARD: `127.0.0.1.example.com`, `localhost.example.com`, a LAN IP
+//     (only `127.0.0.1`, `::1` and `localhost` are loopback here); wrongly REFUSE: `[::1]`,
+//     `localhost`, and an endpoint with no explicit port (its scheme default).
+//   - token, wrongly LEAK: the bearer token is on every request and in no log line; no page
+//     snippet is logged.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use futures::AsyncReadExt as _;
+use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
+use http_client::{AsyncBody, HttpClient, Response};
+use parking_lot::Mutex;
+use serde_json::{Value, json};
+use url::Url;
+
+use crate::memory::{MemoryClient, MemoryError, MemoryScope, ResolvedBy, forward_port};
+use crate::test_log_capture::{LogCapture, kv};
+
+const ENDPOINT: &str = "http://127.0.0.1:49374/mcp";
+
+const STATUS_FIXTURE: &str = r#"{"counts":{"pages_latest":69,"pages_all":169,"sessions":118,"observations":26887,"evidence_rows":0},"scope":{"workspace":"default","project":"zeo-workspace-zed-workspace","resolved_by":"explicit"}}"#;
+const RECENT_FIXTURE: &str = r#"{"hits":[{"id":"01a12878-aaaa","path":"sessions/b1e7.md","title":"Session b1e7","snippet":"17 completed tool calls, over 1s.","rank":1.79e15},{"id":"02b2","path":"gotchas/fs.md","title":"FS gotcha","snippet":"PAGE-TEXT-MUST-NOT-BE-LOGGED","rank":1.2e15}]}"#;
+const UNKNOWN_PROJECT: &str = "project 'zzz' not found in workspace 'default'; did you mean …";
+const UNKNOWN_WORKSPACE: &str = "workspace 'zeo-workspace' not found";
+
+#[derive(Clone, Debug)]
+struct Seen {
+    method: String,
+    tool: Option<String>,
+    arguments: Option<Value>,
+    auth: Option<String>,
+}
+
+#[derive(Clone)]
+enum Answer {
+    Text(&'static str),
+    ToolError(&'static str),
+    RpcError(i64, &'static str),
+    Delayed(Duration, &'static str),
+    Never,
+    Refused,
+}
+
+struct FakeMemory {
+    seen: Arc<Mutex<Vec<Seen>>>,
+}
+
+impl FakeMemory {
+    fn tools_calls(&self) -> Vec<Seen> {
+        self.seen
+            .lock()
+            .iter()
+            .filter(|seen| seen.method == "tools/call")
+            .cloned()
+            .collect()
+    }
+    fn requests(&self) -> Vec<Seen> {
+        self.seen.lock().clone()
+    }
+}
+
+fn json_response(body: Value) -> anyhow::Result<Response<AsyncBody>> {
+    Ok(Response::builder()
+        .status(200)
+        .header("Content-Type", "application/json")
+        .body(AsyncBody::from(body.to_string().into_bytes()))
+        .expect("response"))
+}
+
+/// A fake ai-memory 2.6.2. `answer(tool)` decides each `tools/call`.
+fn fake_memory(
+    executor: BackgroundExecutor,
+    answer: impl Fn(&str) -> Answer + Send + Sync + 'static,
+) -> (Arc<dyn HttpClient>, FakeMemory) {
+    let seen = Arc::new(Mutex::new(Vec::new()));
+    let answer = Arc::new(answer);
+    let client = http_client::FakeHttpClient::create({
+        let seen = seen.clone();
+        move |request| {
+            let seen = seen.clone();
+            let answer = answer.clone();
+            let executor = executor.clone();
+            async move {
+                let auth = request
+                    .headers()
+                    .get("Authorization")
+                    .and_then(|value| value.to_str().ok())
+                    .map(str::to_string);
+                let mut text = String::new();
+                request.into_body().read_to_string(&mut text).await?;
+                let message: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
+                let method = message["method"].as_str().unwrap_or_default().to_string();
+                let tool = message["params"]["name"].as_str().map(str::to_string);
+                seen.lock().push(Seen {
+                    method: method.clone(),
+                    tool: tool.clone(),
+                    arguments: message["params"].get("arguments").cloned(),
+                    auth,
+                });
+                let id = message["id"].clone();
+                match method.as_str() {
+                    "initialize" => json_response(json!({
+                        "jsonrpc": "2.0", "id": id,
+                        "result": {
+                            "protocolVersion": message["params"]["protocolVersion"],
+                            "capabilities": { "tools": {} },
+                            "serverInfo": { "name": "ai-memory", "version": "2.6.2" }
+                        }
+                    })),
+                    "tools/call" => match answer(tool.as_deref().unwrap_or_default()) {
+                        Answer::Text(text) => json_response(json!({
+                            "jsonrpc": "2.0", "id": id,
+                            "result": { "content": [{ "type": "text", "text": text }] }
+                        })),
+                        Answer::ToolError(text) => json_response(json!({
+                            "jsonrpc": "2.0", "id": id,
+                            "result": { "content": [{ "type": "text", "text": text }], "isError": true }
+                        })),
+                        Answer::RpcError(code, text) => json_response(json!({
+                            "jsonrpc": "2.0", "id": id,
+                            "error": { "code": code, "message": text }
+                        })),
+                        Answer::Delayed(delay, text) => {
+                            executor.timer(delay).await;
+                            json_response(json!({
+                                "jsonrpc": "2.0", "id": id,
+                                "result": { "content": [{ "type": "text", "text": text }] }
+                            }))
+                        }
+                        Answer::Never => futures::future::pending().await,
+                        Answer::Refused => {
+                            Err(anyhow::anyhow!("Connection refused (os error 111)"))
+                        }
+                    },
+                    // Notifications (initialized, cancelled): accepted, no body.
+                    _ => Ok(Response::builder()
+                        .status(202)
+                        .body(AsyncBody::default())
+                        .expect("response")),
+                }
+            }
+        }
+    }) as Arc<dyn HttpClient>;
+    (client, FakeMemory { seen })
+}
+
+fn scope(project: &str) -> MemoryScope {
+    MemoryScope {
+        workspace: "default".to_string(),
+        project: project.to_string(),
+        resolved_by: ResolvedBy::Marker,
+    }
+}
+
+fn client(http: Arc<dyn HttpClient>, token: Option<&str>, cx: &mut TestAppContext) -> MemoryClient {
+    MemoryClient::new(
+        Url::parse(ENDPOINT).expect("url"),
+        token.map(str::to_string),
+        http,
+        &cx.to_async(),
+    )
+    .expect("client")
+}
+
+// --- contract fixtures (Q13) --------------------------------------------------------------------
+
+#[gpui::test]
+async fn memory_status_parses_the_captured_fixture(cx: &mut TestAppContext) {
+    let (http, fake) = fake_memory(cx.executor(), |_| Answer::Text(STATUS_FIXTURE));
+    let client = client(http, None, cx);
+    let status = client
+        .status(&scope("zeo-workspace-zed-workspace"))
+        .await
+        .expect("status");
+    assert_eq!(
+        status.server_version, "2.6.2",
+        "from initialize serverInfo.version"
+    );
+    assert_eq!(status.endpoint.to_string(), ENDPOINT);
+    assert_eq!(status.scope.workspace, "default");
+    assert_eq!(status.scope.project, "zeo-workspace-zed-workspace");
+    assert_eq!(status.pages, 69);
+    assert_eq!(status.sessions, 118);
+    let calls = fake.tools_calls();
+    assert_eq!(calls.len(), 1);
+    assert_eq!(calls[0].tool.as_deref(), Some("memory_status"));
+    let args = calls[0].arguments.clone().expect("arguments");
+    assert_eq!(args["workspace"], "default");
+    assert_eq!(args["project"], "zeo-workspace-zed-workspace");
+}
+
+#[gpui::test]
+async fn memory_recent_parses_the_captured_fixture_in_server_order(cx: &mut TestAppContext) {
+    let (http, fake) = fake_memory(cx.executor(), |_| Answer::Text(RECENT_FIXTURE));
+    let client = client(http, None, cx);
+    let pages = client
+        .recent(&scope("zeo-workspace-zed-workspace"), 10)
+        .await
+        .expect("recent");
+    let shown: Vec<(&str, &str)> = pages
+        .iter()
+        .map(|page| (page.title.as_str(), page.path.as_str()))
+        .collect();
+    assert_eq!(
+        shown,
+        vec![
+            ("Session b1e7", "sessions/b1e7.md"),
+            ("FS gotcha", "gotchas/fs.md")
+        ]
+    );
+    let calls = fake.tools_calls();
+    assert_eq!(calls[0].tool.as_deref(), Some("memory_recent"));
+    assert_eq!(calls[0].arguments.clone().expect("arguments")["limit"], 10);
+}
+
+// --- errors (R6.6, R6.8) ------------------------------------------------------------------------
+
+#[gpui::test]
+async fn memory_unknown_workspace_is_a_server_error_not_unknown_project(cx: &mut TestAppContext) {
+    let (http, _) = fake_memory(cx.executor(), |_| {
+        Answer::RpcError(-32602, UNKNOWN_WORKSPACE)
+    });
+    let client = client(http, None, cx);
+    let error = client
+        .status(&scope("zed-workspace"))
+        .await
+        .expect_err("error");
+    assert!(
+        !matches!(error, MemoryError::UnknownProject { .. }),
+        "a missing workspace is not 'no memory recorded': {error}"
+    );
+    assert!(
+        error.to_string().contains(UNKNOWN_WORKSPACE),
+        "the server's text: {error}"
+    );
+}
+
+#[gpui::test]
+async fn memory_unknown_project_has_the_r6_8_text(cx: &mut TestAppContext) {
+    let (http, _) = fake_memory(cx.executor(), |_| Answer::RpcError(-32602, UNKNOWN_PROJECT));
+    let client = client(http, None, cx);
+    let error = client.status(&scope("zzz")).await.expect_err("error");
+    assert!(
+        matches!(error, MemoryError::UnknownProject { .. }),
+        "{error}"
+    );
+    assert_eq!(error.to_string(), "No memory recorded for default/zzz yet.");
+}
+
+#[gpui::test]
+async fn memory_tool_error_shows_the_server_text(cx: &mut TestAppContext) {
+    let (http, _) = fake_memory(cx.executor(), |_| Answer::ToolError("database is locked"));
+    let client = client(http, None, cx);
+    let error = client.recent(&scope("p"), 10).await.expect_err("error");
+    assert!(error.to_string().contains("database is locked"), "{error}");
+}
+
+#[gpui::test]
+async fn memory_malformed_answers_are_errors_not_panics(cx: &mut TestAppContext) {
+    for text in [
+        "not json",
+        r#"{"scope":{}}"#,
+        r#"{"counts":{"pages_latest":"many"}}"#,
+    ] {
+        let (http, _) = fake_memory(cx.executor(), move |_| Answer::Text(text));
+        let client = client(http, None, cx);
+        let error = client.status(&scope("p")).await.expect_err(text);
+        assert!(
+            matches!(error, MemoryError::Malformed { .. }),
+            "{text}: {error}"
+        );
+    }
+    let (http, _) = fake_memory(cx.executor(), |_| Answer::Text(r#"{"nohits":[]}"#));
+    let client = client(http, None, cx);
+    assert!(matches!(
+        client.recent(&scope("p"), 10).await,
+        Err(MemoryError::Malformed { .. })
+    ));
+}
+
+#[gpui::test]
+async fn memory_times_out_at_five_seconds_not_before(cx: &mut TestAppContext) {
+    // Wrongly time out: an answer at 4 s is a result.
+    let (http, _) = fake_memory(cx.executor(), |_| {
+        Answer::Delayed(Duration::from_secs(4), STATUS_FIXTURE)
+    });
+    let client_ok = client(http, None, cx);
+    let task = cx.background_spawn(async move { client_ok.status(&scope("p")).await });
+    cx.executor().run_until_parked();
+    cx.executor().advance_clock(Duration::from_millis(4_500));
+    cx.executor().run_until_parked();
+    assert!(task.await.is_ok(), "an answer within 5 s is not a timeout");
+
+    // No answer: Timeout after 5 s.
+    let (http, _) = fake_memory(cx.executor(), |_| Answer::Never);
+    let client_hang = client(http, None, cx);
+    let task = cx.background_spawn(async move { client_hang.status(&scope("p")).await });
+    cx.executor().run_until_parked();
+    cx.executor().advance_clock(Duration::from_secs(6));
+    cx.executor().run_until_parked();
+    let error = task.await.expect_err("no answer");
+    assert!(matches!(error, MemoryError::Timeout { .. }), "{error}");
+}
+
+#[gpui::test]
+async fn memory_refused_connection_is_unreachable_and_never_retried(cx: &mut TestAppContext) {
+    let (http, fake) = fake_memory(cx.executor(), |_| Answer::Refused);
+    let client = client(http, None, cx);
+    let error = client.status(&scope("p")).await.expect_err("refused");
+    assert!(matches!(error, MemoryError::Unreachable { .. }), "{error}");
+    assert_eq!(
+        error.to_string(),
+        format!("ai-memory is not reachable at {ENDPOINT}")
+    );
+    let after_error = fake.requests().len();
+    cx.executor().advance_clock(Duration::from_secs(120));
+    cx.executor().run_until_parked();
+    assert_eq!(
+        fake.requests().len(),
+        after_error,
+        "no background retry (R6.6)"
+    );
+}
+
+// --- delete (R6.5) ------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn memory_delete_sends_exactly_that_page_in_the_scope(cx: &mut TestAppContext) {
+    let (http, fake) = fake_memory(cx.executor(), |_| Answer::Text(r#"{"deleted":true}"#));
+    let client = client(http, None, cx);
+    client
+        .delete(&scope("zeo-workspace-zed-workspace"), "gotchas/fs.md")
+        .await
+        .expect("delete");
+    let calls = fake.tools_calls();
+    assert_eq!(calls.len(), 1, "one call: {calls:?}");
+    assert_eq!(calls[0].tool.as_deref(), Some("memory_delete_page"));
+    let args = calls[0].arguments.clone().expect("arguments");
+    assert_eq!(
+        args,
+        json!({ "path": "gotchas/fs.md", "workspace": "default", "project": "zeo-workspace-zed-workspace" })
+    );
+}
+
+// --- auth and logs (R6.9, R9.3, R9.4, Q14) -----------------------------------------------------
+
+#[gpui::test]
+async fn memory_token_is_sent_on_every_request_and_never_logged(cx: &mut TestAppContext) {
+    let logs = LogCapture::start();
+    let (http, fake) = fake_memory(cx.executor(), |tool| match tool {
+        "memory_status" => Answer::Text(STATUS_FIXTURE),
+        _ => Answer::Text(RECENT_FIXTURE),
+    });
+    let client = client(http, Some("tok-SECRET-123"), cx);
+    client.status(&scope("p")).await.expect("status");
+    client.recent(&scope("p"), 10).await.expect("recent");
+    let requests = fake.requests();
+    assert!(!requests.is_empty());
+    for seen in &requests {
+        assert_eq!(
+            seen.auth.as_deref(),
+            Some("Bearer tok-SECRET-123"),
+            "{}",
+            seen.method
+        );
+    }
+    let all = logs.all_text();
+    assert!(!all.contains("tok-SECRET-123"), "the token is never logged");
+    assert!(
+        !all.contains("PAGE-TEXT-MUST-NOT-BE-LOGGED"),
+        "page contents are never logged"
+    );
+}
+
+#[gpui::test]
+async fn memory_no_token_sends_no_authorization(cx: &mut TestAppContext) {
+    let (http, fake) = fake_memory(cx.executor(), |_| Answer::Text(STATUS_FIXTURE));
+    let client = client(http, None, cx);
+    client.status(&scope("p")).await.expect("status");
+    assert!(fake.requests().iter().all(|seen| seen.auth.is_none()));
+}
+
+#[gpui::test]
+async fn memory_logs_one_structured_line_per_call(cx: &mut TestAppContext) {
+    let logs = LogCapture::start();
+    let (http, _) = fake_memory(cx.executor(), |tool| match tool {
+        "memory_status" => Answer::Text(STATUS_FIXTURE),
+        _ => Answer::RpcError(-32602, UNKNOWN_PROJECT),
+    });
+    let client = client(http, None, cx);
+    client.status(&scope("p")).await.expect("status");
+    let _ = client.recent(&scope("p"), 10).await;
+    let lines = logs.matching("agent_memory event=call");
+    assert_eq!(lines.len(), 2, "one line per call: {lines:?}");
+    let ok = kv(&lines[0].1);
+    assert_eq!(ok.get("tool").map(String::as_str), Some("memory_status"));
+    assert_eq!(ok.get("outcome").map(String::as_str), Some("ok"));
+    assert!(
+        ok.get("ms").is_some_and(|ms| ms.parse::<u64>().is_ok()),
+        "{:?}",
+        lines[0]
+    );
+    let failed = kv(&lines[1].1);
+    assert_eq!(
+        failed.get("tool").map(String::as_str),
+        Some("memory_recent")
+    );
+    assert_ne!(failed.get("outcome").map(String::as_str), Some("ok"));
+}
+
+// --- forward_port (R7.1, R7.3) ------------------------------------------------------------------
+
+#[test]
+fn memory_forward_port_only_for_loopback_hosts() {
+    for (endpoint, expected) in [
+        ("http://127.0.0.1.example.com:9000/mcp", None),
+        ("http://localhost.example.com:9000/mcp", None),
+        ("http://mem.example.com:49374/mcp", None),
+        ("http://10.0.0.5:49374/mcp", None),
+        ("http://127.0.0.1:49374/mcp", Some(49374)),
+        ("http://localhost:7000/mcp", Some(7000)),
+        ("http://[::1]:5000/mcp", Some(5000)),
+        ("http://127.0.0.1/mcp", Some(80)),
+    ] {
+        assert_eq!(
+            forward_port(&Url::parse(endpoint).expect("url")),
+            expected,
+            "{endpoint}"
+        );
+    }
+}
+
+// Executor-authored (task 6.2, after the tech review): the hardening cases the authored tests
+// above do not reach. Each fake answers per JSON-RPC method, so an HTTP status can be put on any
+// leg of the handshake.
+mod review_fixes {
+    use std::sync::atomic::{AtomicUsize, Ordering};
+
+    use super::*;
+
+    type Respond = dyn Fn(&str, &Value) -> anyhow::Result<Response<AsyncBody>> + Send + Sync;
+
+    fn raw(status: u16, body: &str) -> anyhow::Result<Response<AsyncBody>> {
+        Ok(Response::builder()
+            .status(status)
+            .header("Content-Type", "text/html")
+            .body(AsyncBody::from(body.as_bytes().to_vec()))
+            .expect("response"))
+    }
+
+    fn initialized(message: &Value) -> anyhow::Result<Response<AsyncBody>> {
+        json_response(json!({
+            "jsonrpc": "2.0", "id": message["id"],
+            "result": {
+                "protocolVersion": message["params"]["protocolVersion"],
+                "capabilities": { "tools": {} },
+                "serverInfo": { "name": "ai-memory", "version": "2.6.2" }
+            }
+        }))
+    }
+
+    fn tool_text(message: &Value, text: &str) -> anyhow::Result<Response<AsyncBody>> {
+        json_response(json!({
+            "jsonrpc": "2.0", "id": message["id"],
+            "result": { "content": [{ "type": "text", "text": text }] }
+        }))
+    }
+
+    /// `respond(method, message)`; returns the methods seen, `tools/call` as `tools/call:<tool>`.
+    fn fake(respond: Arc<Respond>) -> (Arc<dyn HttpClient>, Arc<Mutex<Vec<String>>>) {
+        let seen = Arc::new(Mutex::new(Vec::new()));
+        let client = http_client::FakeHttpClient::create({
+            let seen = seen.clone();
+            move |request| {
+                let seen = seen.clone();
+                let respond = respond.clone();
+                async move {
+                    let mut text = String::new();
+                    request.into_body().read_to_string(&mut text).await?;
+                    let message: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
+                    let method = message["method"].as_str().unwrap_or_default().to_string();
+                    seen.lock().push(match message["params"]["name"].as_str() {
+                        Some(tool) if method == "tools/call" => format!("tools/call:{tool}"),
+                        _ => method.clone(),
+                    });
+                    respond(&method, &message)
+                }
+            }
+        }) as Arc<dyn HttpClient>;
+        (client, seen)
+    }
+
+    #[gpui::test]
+    async fn rejected_initialized_notification_fails_the_start_not_the_call(
+        cx: &mut TestAppContext,
+    ) {
+        let notifications = Arc::new(AtomicUsize::new(0));
+        let (http, seen) = fake(Arc::new({
+            let notifications = notifications.clone();
+            move |method, message| match method {
+                "initialize" => initialized(message),
+                "notifications/initialized" => {
+                    if notifications.fetch_add(1, Ordering::SeqCst) == 0 {
+                        raw(400, "Bad Request: no session")
+                    } else {
+                        raw(202, "")
+                    }
+                }
+                _ => tool_text(message, STATUS_FIXTURE),
+            }
+        }));
+        let client = client(http, None, cx);
+        let error = client
+            .status(&scope("p"))
+            .await
+            .expect_err("the start fails");
+        assert_eq!(
+            error,
+            MemoryError::Server("HTTP 400: Bad Request: no session".to_string())
+        );
+        assert!(
+            !seen
+                .lock()
+                .iter()
+                .any(|method| method.starts_with("tools/call")),
+            "no tool call on a failed start: {:?}",
+            seen.lock()
+        );
+
+        let status = client.status(&scope("p")).await.expect("a fresh session");
+        assert_eq!(status.pages, 69);
+        assert_eq!(
+            seen.lock()
+                .iter()
+                .filter(|method| *method == "initialize")
+                .count(),
+            2,
+            "the failed session was dropped: {:?}",
+            seen.lock()
+        );
+    }
+
+    #[gpui::test]
+    async fn token_over_plain_http_to_a_remote_host_is_refused(cx: &mut TestAppContext) {
+        let (http, _) = fake(Arc::new(|_, message| tool_text(message, "{}")));
+        for endpoint in [
+            "http://mem.example.com:49374/mcp",
+            "http://user:pw-SECRET@10.0.0.5:49374/mcp",
+        ] {
+            let error = MemoryClient::new(
+                Url::parse(endpoint).expect("url"),
+                Some("tok-SECRET-123".to_string()),
+                http.clone(),
+                &cx.to_async(),
+            )
+            .expect_err(endpoint)
+            .to_string();
+            assert!(!error.contains("tok-SECRET-123"), "{error}");
+            assert!(!error.contains("pw-SECRET"), "{error}");
+            assert!(
+                error.contains(&crate::memory::display_endpoint(
+                    &Url::parse(endpoint).expect("url")
+                )),
+                "{error}"
+            );
+        }
+        for (endpoint, token) in [
+            ("http://127.0.0.1:49374/mcp", Some("tok")),
+            ("http://localhost:49374/mcp", Some("tok")),
+            ("http://[::1]:49374/mcp", Some("tok")),
+            ("https://mem.example.com/mcp", Some("tok")),
+            ("http://mem.example.com:49374/mcp", None),
+        ] {
+            assert!(
+                MemoryClient::new(
+                    Url::parse(endpoint).expect("url"),
+                    token.map(str::to_string),
+                    http.clone(),
+                    &cx.to_async(),
+                )
+                .is_ok(),
+                "{endpoint} with {token:?}"
+            );
+        }
+    }
+
+    #[gpui::test]
+    async fn unauthorized_is_the_rejected_token_text(cx: &mut TestAppContext) {
+        let (http, _) = fake(Arc::new(|_, _| raw(401, "Unauthorized")));
+        let client = client(http, Some("tok-WRONG"), cx);
+        let error = client.status(&scope("p")).await.expect_err("401");
+        assert_eq!(
+            error,
+            MemoryError::Server("ai-memory rejected the auth token (HTTP 401)".to_string())
+        );
+    }
+
+    #[gpui::test]
+    async fn handshake_rpc_error_shows_the_server_text(cx: &mut TestAppContext) {
+        let (http, _) = fake(Arc::new(|method, message| match method {
+            "initialize" => json_response(json!({
+                "jsonrpc": "2.0", "id": message["id"],
+                "error": { "code": -32602, "message": "unsupported protocol version" }
+            })),
+            _ => tool_text(message, STATUS_FIXTURE),
+        }));
+        let client = client(http, None, cx);
+        let error = client.status(&scope("p")).await.expect_err("rpc error");
+        assert_eq!(
+            error,
+            MemoryError::Server("unsupported protocol version".to_string())
+        );
+    }
+
+    #[gpui::test]
+    async fn http_error_body_is_bounded(cx: &mut TestAppContext) {
+        let long_line = "x".repeat(300);
+        let page: &'static str =
+            Box::leak(format!("{long_line}\n<p>second line</p>\n").into_boxed_str());
+        for failing in ["initialize", "tools/call"] {
+            let (http, _) = fake(Arc::new(move |method, message| {
+                if method == failing {
+                    raw(502, page)
+                } else if method == "initialize" {
+                    initialized(message)
+                } else {
+                    tool_text(message, STATUS_FIXTURE)
+                }
+            }));
+            let client = client(http, None, cx);
+            let MemoryError::Server(text) = client.status(&scope("p")).await.expect_err(failing)
+            else {
+                panic!("{failing}: not a server error");
+            };
+            assert!(text.starts_with("HTTP 502: xxx"), "{failing}: {text}");
+            assert!(!text.contains('\n'), "{failing}: {text}");
+            assert!(!text.contains("second line"), "{failing}: {text}");
+            assert!(text.chars().count() <= 200, "{failing}: {text}");
+        }
+    }
+
+    #[gpui::test]
+    async fn other_not_found_in_workspace_errors_are_not_unknown_project(cx: &mut TestAppContext) {
+        let (http, _) = fake(Arc::new(|method, message| match method {
+            "initialize" => initialized(message),
+            _ => json_response(json!({
+                "jsonrpc": "2.0", "id": message["id"],
+                "error": { "code": -32602, "message": "page 'gotchas/fs.md' not found in workspace 'default'" }
+            })),
+        }));
+        let client = client(http, None, cx);
+        let error = client
+            .delete(&scope("p"), "gotchas/fs.md")
+            .await
+            .expect_err("rpc error");
+        assert_eq!(
+            error,
+            MemoryError::Server(
+                "page 'gotchas/fs.md' not found in workspace 'default'".to_string()
+            )
+        );
+    }
+
+    // L1: the query and fragment can carry a key; nothing shown or logged keeps them.
+    #[gpui::test]
+    async fn shown_endpoint_drops_userinfo_query_and_fragment(cx: &mut TestAppContext) {
+        let raw_endpoint = "http://user:pw-SECRET@127.0.0.1:49374/mcp?key=q-SECRET#f-SECRET";
+        let endpoint = Url::parse(raw_endpoint).expect("url");
+        assert_eq!(
+            crate::memory::display_endpoint(&endpoint),
+            "http://127.0.0.1:49374/mcp"
+        );
+
+        let (http, _) = fake(Arc::new(|method, message| match method {
+            "initialize" => initialized(message),
+            _ => tool_text(message, STATUS_FIXTURE),
+        }));
+        let client =
+            MemoryClient::new(endpoint.clone(), None, http, &cx.to_async()).expect("client");
+        let status = client.status(&scope("p")).await.expect("status");
+        assert_eq!(status.endpoint.as_str(), "http://127.0.0.1:49374/mcp");
+        assert!(!format!("{client:?}").contains("SECRET"), "{client:?}");
+
+        let (http, _) = fake(Arc::new(|_, _| Err(anyhow::anyhow!("Connection refused"))));
+        let client = MemoryClient::new(endpoint, None, http, &cx.to_async()).expect("client");
+        let error = client.status(&scope("p")).await.expect_err("refused");
+        assert_eq!(
+            error.to_string(),
+            "ai-memory is not reachable at http://127.0.0.1:49374/mcp"
+        );
+    }
+
+    // L4: a server's error text is shown as its first line, at most 200 characters.
+    #[gpui::test]
+    async fn rpc_and_tool_error_texts_are_bounded(cx: &mut TestAppContext) {
+        let first_line = "e".repeat(300);
+        let text: &'static str =
+            Box::leak(format!("{first_line}\nTraceback: second line\n").into_boxed_str());
+        for as_tool_error in [false, true] {
+            let (http, _) = fake(Arc::new(move |method, message| match method {
+                "initialize" => initialized(message),
+                _ if as_tool_error => json_response(json!({
+                    "jsonrpc": "2.0", "id": message["id"],
+                    "result": { "content": [{ "type": "text", "text": text }], "isError": true }
+                })),
+                _ => json_response(json!({
+                    "jsonrpc": "2.0", "id": message["id"],
+                    "error": { "code": -32603, "message": text }
+                })),
+            }));
+            let memory = client(http, None, cx);
+            let MemoryError::Server(shown) =
+                memory.recent(&scope("p"), 10).await.expect_err("error")
+            else {
+                panic!("tool error {as_tool_error}: not a server error");
+            };
+            assert_eq!(shown, "e".repeat(200), "tool error {as_tool_error}");
+        }
+
+        let (http, _) = fake(Arc::new(move |method, message| match method {
+            "initialize" => json_response(json!({
+                "jsonrpc": "2.0", "id": message["id"],
+                "error": { "code": -32603, "message": text }
+            })),
+            _ => tool_text(message, STATUS_FIXTURE),
+        }));
+        let handshake = client(http, None, cx);
+        assert_eq!(
+            handshake.status(&scope("p")).await.expect_err("handshake"),
+            MemoryError::Server("e".repeat(200))
+        );
+    }
+
+    // L5: a 401 without a configured token asks for one instead of blaming a token never sent.
+    #[gpui::test]
+    async fn unauthorized_without_a_token_asks_for_one(cx: &mut TestAppContext) {
+        let (http, _) = fake(Arc::new(|_, _| raw(401, "Unauthorized")));
+        let without_token = client(http, None, cx);
+        assert_eq!(
+            without_token.status(&scope("p")).await.expect_err("401"),
+            MemoryError::Server(
+                "ai-memory requires an auth token (HTTP 401): set AI_MEMORY_AUTH_TOKEN".to_string()
+            )
+        );
+
+        let (http, _) = fake(Arc::new(|_, _| raw(401, "Unauthorized")));
+        let with_token = client(http, Some("tok-WRONG"), cx);
+        assert_eq!(
+            with_token.status(&scope("p")).await.expect_err("401"),
+            MemoryError::Server("ai-memory rejected the auth token (HTTP 401)".to_string())
+        );
+    }
+}
diff --git a/crates/agent_sandbox/src/probe.rs b/crates/agent_sandbox/src/probe.rs
new file mode 100644
index 0000000..3a78859
--- /dev/null
+++ b/crates/agent_sandbox/src/probe.rs
@@ -0,0 +1,387 @@
+//! Locating ai-jail, reading its version and whether it offers `--forward-port`.
+//!
+//! The answer is cached per binary identity, so an agent start costs one `stat` once warm.
+
+use std::collections::HashMap;
+use std::fmt;
+use std::io;
+use std::os::unix::fs::MetadataExt as _;
+use std::path::{Path, PathBuf};
+use std::pin::pin;
+use std::process::Stdio;
+use std::sync::{LazyLock, Mutex, PoisonError};
+use std::time::Duration;
+
+use futures::{AsyncReadExt as _, FutureExt as _};
+use gpui::BackgroundExecutor;
+use semver::Version;
+
+/// The first release with the fixes for GHSA-frgp-q3qc-g78p and GHSA-w976-gw52-hvx2.
+pub const MIN_SAFE: Version = Version::new(2, 4, 0);
+
+const TIMEOUT: Duration = Duration::from_secs(2);
+const MAX_OUTPUT_BYTES: u64 = 64 * 1024;
+const MAX_SHOWN_CHARS: usize = 120;
+const BUSY_RETRIES: usize = 20;
+const BUSY_BACKOFF: Duration = Duration::from_millis(10);
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct JailBinary {
+    pub version: Version,
+    pub supports_forward_port: bool,
+}
+
+#[derive(Debug)]
+pub enum ProbeError {
+    Spawn { path: PathBuf, source: io::Error },
+    TimedOut { path: PathBuf },
+    Exited { path: PathBuf, code: Option<i32> },
+    Unparsable { path: PathBuf, first_line: String },
+}
+
+impl fmt::Display for ProbeError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Spawn { path, source } => {
+                write!(f, "could not run {}: {source}", path.display())
+            }
+            Self::TimedOut { path } => write!(
+                f,
+                "{} --version did not answer within {} seconds",
+                path.display(),
+                TIMEOUT.as_secs()
+            ),
+            Self::Exited {
+                path,
+                code: Some(code),
+            } => write!(f, "{} --version exited with code {code}", path.display()),
+            Self::Exited { path, code: None } => {
+                write!(f, "{} --version was ended by a signal", path.display())
+            }
+            Self::Unparsable { path, first_line } => write!(
+                f,
+                "{} --version printed {first_line:?}, not \"ai-jail X.Y.Z\"",
+                path.display()
+            ),
+        }
+    }
+}
+
+impl std::error::Error for ProbeError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            Self::Spawn { source, .. } => Some(source),
+            _ => None,
+        }
+    }
+}
+
+/// The `ai_jail_path` setting (`~` expanded, returned even when it does not exist, so the
+/// refusal names it), else `ai-jail` on `PATH`.
+pub fn locate(setting: Option<&Path>) -> Option<PathBuf> {
+    match setting {
+        Some(path) => Some(expand_home(path)),
+        None => which::which("ai-jail").ok(),
+    }
+}
+
+fn expand_home(path: &Path) -> PathBuf {
+    match path.strip_prefix("~") {
+        Ok(rest) => paths::home_dir().join(rest),
+        Err(_) => path.to_path_buf(),
+    }
+}
+
+pub fn is_outdated(version: &Version) -> bool {
+    *version < MIN_SAFE
+}
+
+/// The R3.5 warning, shared by the settings page and the status icon's tooltip.
+pub fn outdated_warning(version: &Version) -> String {
+    format!(
+        "ai-jail {version} is older than {MIN_SAFE} and is affected by GHSA-frgp-q3qc-g78p and \
+         GHSA-w976-gw52-hvx2. Update ai-jail to {MIN_SAFE} or newer."
+    )
+}
+
+/// Runs `<path> --version` (2 s timeout) and parses `ai-jail X.Y.Z`.
+pub async fn version(path: &Path, executor: &BackgroundExecutor) -> Result<Version, ProbeError> {
+    let identity = identify(path);
+    if let Some((key, identity)) = &identity
+        && let Some(entry) = cached(key, identity)
+    {
+        return Ok(entry.version);
+    }
+    let program = identity.as_ref().map_or(path, |(key, _)| key.as_path());
+    let version = read_version(path, program, executor).await?;
+    if let Some((key, identity)) = identity {
+        store(key, identity, version.clone(), None);
+    }
+    Ok(version)
+}
+
+/// [`version`], then `<path> --help` (same timeout) for `--forward-port`. A failing `--help`
+/// clears the flag; it is not a refusal.
+pub async fn probe(path: &Path, executor: &BackgroundExecutor) -> Result<JailBinary, ProbeError> {
+    let identity = identify(path);
+    let cached_entry = identity
+        .as_ref()
+        .and_then(|(key, identity)| cached(key, identity));
+    if let Some(CacheEntry {
+        version,
+        supports_forward_port: Some(supports_forward_port),
+        ..
+    }) = cached_entry
+    {
+        return Ok(JailBinary {
+            version,
+            supports_forward_port,
+        });
+    }
+    let program = identity.as_ref().map_or(path, |(key, _)| key.as_path());
+    let version = match cached_entry {
+        Some(entry) => entry.version,
+        None => read_version(path, program, executor).await?,
+    };
+    let supports_forward_port = match run(program, "--help", executor).await {
+        Ok(Output {
+            success: true,
+            stdout,
+            ..
+        }) => help_lists_forward_port(&String::from_utf8_lossy(&stdout)),
+        Ok(_) | Err(_) => false,
+    };
+    log::debug!(
+        "agent_sandbox event=probe path={} version={version} forward_port={supports_forward_port}",
+        path.display()
+    );
+    if let Some((key, identity)) = identity {
+        store(key, identity, version.clone(), Some(supports_forward_port));
+    }
+    Ok(JailBinary {
+        version,
+        supports_forward_port,
+    })
+}
+
+async fn read_version(
+    path: &Path,
+    program: &Path,
+    executor: &BackgroundExecutor,
+) -> Result<Version, ProbeError> {
+    let output = run(program, "--version", executor)
+        .await
+        .map_err(|failure| match failure {
+            RunFailure::Spawn(source) => ProbeError::Spawn {
+                path: path.to_path_buf(),
+                source,
+            },
+            RunFailure::TimedOut => ProbeError::TimedOut {
+                path: path.to_path_buf(),
+            },
+        })?;
+    if !output.success {
+        return Err(ProbeError::Exited {
+            path: path.to_path_buf(),
+            code: output.code,
+        });
+    }
+    let text = String::from_utf8_lossy(&output.stdout);
+    let first_line = text.lines().next().unwrap_or_default().trim_end();
+    first_line
+        .strip_prefix("ai-jail ")
+        .and_then(|version| Version::parse(version).ok())
+        .ok_or_else(|| ProbeError::Unparsable {
+            path: path.to_path_buf(),
+            first_line: truncate(first_line),
+        })
+}
+
+/// A binary printing 64 KiB without a newline must not flood the settings page or the log.
+fn truncate(line: &str) -> String {
+    match line.char_indices().nth(MAX_SHOWN_CHARS) {
+        Some((cut, _)) => format!("{}…", &line[..cut]),
+        None => line.to_string(),
+    }
+}
+
+/// True when the option column of a help line is `--forward-port`; prose that mentions the flag
+/// and longer option names do not count.
+///
+/// The option column is the indentation of the first line that starts with `-`. Wrapped
+/// descriptions sit deeper and may themselves start with a flag (2.8.1's `--env` entry wraps
+/// onto a line beginning with `--env-from-file`), so deeper lines are never options.
+fn help_lists_forward_port(help: &str) -> bool {
+    let indentation = |line: &str| line.len() - line.trim_start().len();
+    let Some(option_column) = help
+        .lines()
+        .find(|line| line.trim_start().starts_with('-'))
+        .map(indentation)
+    else {
+        return false;
+    };
+    help.lines().any(|line| {
+        if indentation(line) > option_column {
+            return false;
+        }
+        let option = strip_short_flag(line.trim_start());
+        option
+            .strip_prefix("--forward-port")
+            .is_some_and(|rest| match rest.chars().next() {
+                None => true,
+                Some(next) => next.is_whitespace() || matches!(next, '<' | '=' | '['),
+            })
+    })
+}
+
+fn strip_short_flag(line: &str) -> &str {
+    let mut chars = line.chars();
+    match (chars.next(), chars.next(), chars.next()) {
+        (Some('-'), Some(letter), Some(',')) if letter.is_ascii_alphanumeric() => {
+            chars.as_str().trim_start()
+        }
+        _ => line,
+    }
+}
+
+struct Output {
+    success: bool,
+    code: Option<i32>,
+    stdout: Vec<u8>,
+}
+
+enum RunFailure {
+    Spawn(io::Error),
+    TimedOut,
+}
+
+async fn run(
+    program: &Path,
+    argument: &str,
+    executor: &BackgroundExecutor,
+) -> Result<Output, RunFailure> {
+    let mut work = pin!(run_untimed(program, argument, executor).fuse());
+    let mut timer = pin!(executor.timer(TIMEOUT).fuse());
+    futures::select_biased! {
+        output = work => output.map_err(RunFailure::Spawn),
+        () = timer => Err(RunFailure::TimedOut),
+    }
+}
+
+async fn run_untimed(
+    program: &Path,
+    argument: &str,
+    executor: &BackgroundExecutor,
+) -> io::Result<Output> {
+    let mut child = spawn(program, argument, executor).await?;
+    let mut stdout = Vec::new();
+    if let Some(pipe) = child.stdout.take() {
+        // Reading past the cap is never needed; dropping the pipe afterwards lets a chatty child
+        // finish instead of blocking on a full pipe.
+        pipe.take(MAX_OUTPUT_BYTES).read_to_end(&mut stdout).await?;
+    }
+    let status = child.status().await?;
+    Ok(Output {
+        success: status.success(),
+        code: status.code(),
+        stdout,
+    })
+}
+
+/// A binary being written — mid-upgrade, or held open by a sibling's fork — fails exec with
+/// ETXTBSY for a moment; wait it out instead of reporting ai-jail missing.
+async fn spawn(
+    program: &Path,
+    argument: &str,
+    executor: &BackgroundExecutor,
+) -> io::Result<smol::process::Child> {
+    let mut attempt = 0;
+    loop {
+        let mut command = smol::process::Command::new(program);
+        command
+            .arg(argument)
+            .env_clear()
+            .stdin(Stdio::null())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::null())
+            .kill_on_drop(true);
+        // The probe child needs no secret: only what locating its interpreter takes.
+        for name in ["PATH", "HOME"] {
+            if let Some(value) = std::env::var_os(name) {
+                command.env(name, value);
+            }
+        }
+        match command.spawn() {
+            Err(error) if error.kind() == io::ErrorKind::ExecutableFileBusy => {
+                attempt += 1;
+                if attempt >= BUSY_RETRIES {
+                    return Err(error);
+                }
+                executor.timer(BUSY_BACKOFF).await;
+            }
+            result => return result,
+        }
+    }
+}
+
+/// What makes a cached answer stale: a replaced binary keeps neither its inode nor its ctime,
+/// even when its mtime is restored (Nix stores every file with mtime=1).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct BinaryIdentity {
+    dev: u64,
+    ino: u64,
+    len: u64,
+    mtime: i128,
+    ctime: i128,
+}
+
+#[derive(Clone)]
+struct CacheEntry {
+    identity: BinaryIdentity,
+    version: Version,
+    supports_forward_port: Option<bool>,
+}
+
+static CACHE: LazyLock<Mutex<HashMap<PathBuf, CacheEntry>>> =
+    LazyLock::new(|| Mutex::new(HashMap::new()));
+
+fn identify(path: &Path) -> Option<(PathBuf, BinaryIdentity)> {
+    let canonical = std::fs::canonicalize(path).ok()?;
+    let metadata = std::fs::metadata(&canonical).ok()?;
+    let nanos = |seconds: i64, nanoseconds: i64| {
+        i128::from(seconds) * 1_000_000_000 + i128::from(nanoseconds)
+    };
+    let identity = BinaryIdentity {
+        dev: metadata.dev(),
+        ino: metadata.ino(),
+        len: metadata.len(),
+        mtime: nanos(metadata.mtime(), metadata.mtime_nsec()),
+        ctime: nanos(metadata.ctime(), metadata.ctime_nsec()),
+    };
+    Some((canonical, identity))
+}
+
+fn cached(key: &Path, identity: &BinaryIdentity) -> Option<CacheEntry> {
+    let cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
+    cache
+        .get(key)
+        .filter(|entry| entry.identity == *identity)
+        .cloned()
+}
+
+fn store(
+    key: PathBuf,
+    identity: BinaryIdentity,
+    version: Version,
+    supports_forward_port: Option<bool>,
+) {
+    let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
+    cache.insert(
+        key,
+        CacheEntry {
+            identity,
+            version,
+            supports_forward_port,
+        },
+    );
+}
diff --git a/crates/agent_sandbox/src/probe_tests.rs b/crates/agent_sandbox/src/probe_tests.rs
new file mode 100644
index 0000000..dfedddf
--- /dev/null
+++ b/crates/agent_sandbox/src/probe_tests.rs
@@ -0,0 +1,531 @@
+// Story 043, task 2.2 — locating ai-jail, reading its version, the outdated check (R1.9, R3.5,
+// R5.3; A3, A4; Q4). Wired in the crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod probe_tests;
+//
+// Validation filter: `probe` (every test path is `probe_tests::…`).
+//
+// Contract (design.md §agent_sandbox::probe). Assumed signatures — adjust the `use`/call only:
+//   - `crate::probe::locate(setting: Option<&Path>) -> Option<PathBuf>`
+//   - `crate::probe::version(path: &Path) -> impl Future<Output = Result<semver::Version, ProbeError>>`
+//     (2 s timeout; cached per (path, mtime)). If the implementation needs an executor argument,
+//     pass `cx.background_executor.clone()` — the assertions stay.
+//   - `crate::probe::MIN_SAFE: semver::Version` (2.4.0), `crate::probe::is_outdated(&Version) -> bool`
+//   - `ProbeError: Display`, naming the path (and the exit code for a non-zero exit).
+//
+// Each test uses fake `ai-jail` shell scripts in its own tempdir; the cache is process-wide, so no
+// two tests share a script path.
+//
+// Hostile halves first:
+//   - version, wrongly ACCEPT: `ai-jailer 2.8.1` (another program), `ai-jail 2.8` (not semver),
+//     a hang, a non-zero exit printing a valid version.
+//   - outdated, wrongly COLLAPSE by string order: `10.0.0` is newer than `2.4.0`; a pre-release
+//     `2.4.0-rc.1` is older than the fixing release.
+//   - cache, wrongly COLLAPSE: a changed mtime re-probes; two paths with the same mtime do not
+//     share an answer. Wrongly SPLIT: the same (path, mtime) never spawns twice.
+
+use std::os::unix::fs::PermissionsExt as _;
+use std::path::{Path, PathBuf};
+use std::time::{Duration, Instant, SystemTime};
+
+use gpui::TestAppContext;
+use semver::Version;
+
+use crate::probe::{MIN_SAFE, is_outdated, locate, version};
+
+fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
+    let path = dir.join(name);
+    std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script");
+    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
+    path
+}
+
+fn set_mtime(path: &Path, time: SystemTime) {
+    std::fs::File::options()
+        .write(true)
+        .open(path)
+        .expect("open script")
+        .set_modified(time)
+        .expect("set mtime");
+}
+
+fn v(text: &str) -> Version {
+    Version::parse(text).expect("test version")
+}
+
+// --- version: hostile ------------------------------------------------------------------------
+
+#[gpui::test]
+async fn probe_refuses_output_that_is_not_ai_jail_semver(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    for (name, output) in [
+        ("other", "echo 'ai-jailer 2.8.1'"),
+        ("short", "echo 'ai-jail 2.8'"),
+        ("garbage", "echo 'hello'"),
+        ("empty", "true"),
+    ] {
+        let path = script(tmp.path(), name, output);
+        let error = version(&path, &cx.executor())
+            .await
+            .expect_err(&format!("{name}: {output} must not parse as a version"));
+        assert!(
+            error.to_string().contains(&path.display().to_string())
+                || error.to_string().contains(name),
+            "{name}: the error names the binary: {error}"
+        );
+    }
+}
+
+#[gpui::test]
+async fn probe_refuses_a_non_zero_exit_even_with_a_version(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let path = script(tmp.path(), "ai-jail", "echo 'ai-jail 2.8.1'; exit 3");
+    let error = version(&path, &cx.executor())
+        .await
+        .expect_err("exit 3 is a failure");
+    let text = error.to_string();
+    assert!(
+        text.contains(&path.display().to_string()),
+        "names the path: {text}"
+    );
+    assert!(text.contains('3'), "names the exit code: {text}");
+}
+
+#[gpui::test]
+async fn probe_refuses_a_missing_binary(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let path = tmp.path().join("ai-jail");
+    let error = version(&path, &cx.executor())
+        .await
+        .expect_err("a missing binary has no version");
+    assert!(
+        error.to_string().contains(&path.display().to_string()),
+        "{error}"
+    );
+}
+
+#[gpui::test]
+async fn probe_times_out_a_hanging_binary(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let path = script(tmp.path(), "ai-jail", "exec sleep 30");
+    let started = Instant::now();
+    let task = cx.executor().spawn({
+        let path = path.clone();
+        let executor = cx.executor();
+        async move { version(&path, &executor).await }
+    });
+    // Poll once so an executor-timer timeout is registered, then move the fake clock past 2 s.
+    // A real-time timeout (smol) simply elapses during the await.
+    cx.executor().run_until_parked();
+    cx.executor().advance_clock(Duration::from_secs(3));
+    let result = task.await;
+    assert!(result.is_err(), "a hang is an error, got {result:?}");
+    assert!(
+        started.elapsed() < Duration::from_secs(15),
+        "the probe gave up instead of waiting for the 30 s sleep"
+    );
+}
+
+// --- version: benign -------------------------------------------------------------------------
+
+#[gpui::test]
+async fn probe_reads_the_installed_version_shapes(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    for (name, output, expected) in [
+        ("a", "echo 'ai-jail 2.8.1'", "2.8.1"),
+        ("b", "printf 'ai-jail 2.7.0\\n'", "2.7.0"),
+        ("c", "echo 'ai-jail 2.3.9'", "2.3.9"),
+    ] {
+        let path = script(tmp.path(), name, output);
+        assert_eq!(
+            version(&path, &cx.executor()).await.expect(name),
+            v(expected),
+            "{name}"
+        );
+    }
+}
+
+// --- cache ------------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn probe_cache_is_keyed_by_path_and_mtime(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let counter = tmp.path().join("spawns");
+    let body = |ver: &str| format!("echo x >> '{}'\necho 'ai-jail {ver}'", counter.display());
+    let spawns = || {
+        std::fs::read_to_string(&counter)
+            .map(|text| text.lines().count())
+            .unwrap_or(0)
+    };
+    let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
+
+    let a = script(tmp.path(), "a-ai-jail", &body("2.8.1"));
+    set_mtime(&a, t0);
+    assert_eq!(
+        version(&a, &cx.executor()).await.expect("first probe"),
+        v("2.8.1")
+    );
+    assert_eq!(
+        version(&a, &cx.executor()).await.expect("cached probe"),
+        v("2.8.1")
+    );
+    assert_eq!(spawns(), 1, "the same (path, mtime) never spawns twice");
+
+    // Same path, new content, mtime restored: re-probed. The cache key is the binary's identity
+    // (dev, ino, len, mtime, ctime), so a replacement under an identical mtime (Nix sets mtime=1)
+    // must not serve the stale version (tech review, story 043 deviations.yaml 2.2).
+    std::fs::write(&a, format!("#!/bin/sh\n{}\n", body("2.3.9"))).expect("rewrite");
+    set_mtime(&a, t0);
+    assert_eq!(
+        version(&a, &cx.executor()).await.expect("re-probe"),
+        v("2.3.9"),
+        "new content under the same mtime re-probes"
+    );
+    assert_eq!(spawns(), 2);
+
+    // New mtime: re-probed.
+    set_mtime(&a, t0 + Duration::from_secs(60));
+    assert_eq!(
+        version(&a, &cx.executor()).await.expect("re-probe"),
+        v("2.3.9"),
+        "a changed mtime re-probes"
+    );
+    assert_eq!(spawns(), 3);
+
+    // Another path with the same mtime has its own answer.
+    let b = script(tmp.path(), "b-ai-jail", &body("2.7.0"));
+    set_mtime(&b, t0 + Duration::from_secs(60));
+    assert_eq!(
+        version(&b, &cx.executor()).await.expect("other path"),
+        v("2.7.0")
+    );
+    assert_eq!(spawns(), 4);
+}
+
+// --- outdated (A3) ------------------------------------------------------------------------------
+
+#[test]
+fn probe_outdated_compares_semver_not_strings() {
+    assert_eq!(MIN_SAFE, v("2.4.0"));
+    assert!(!is_outdated(&v("10.0.0")), "10.0.0 is newer than 2.4.0");
+    assert!(
+        is_outdated(&v("2.4.0-rc.1")),
+        "a pre-release precedes the fixing release"
+    );
+    assert!(is_outdated(&v("2.3.9")));
+    assert!(!is_outdated(&v("2.4.0")));
+    assert!(!is_outdated(&v("2.7.0")));
+    assert!(!is_outdated(&v("2.8.1")));
+}
+
+// --- locate -----------------------------------------------------------------------------------
+
+#[test]
+fn probe_locate_prefers_the_setting_then_path() {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let configured = script(tmp.path(), "ai-jail", "echo 'ai-jail 2.8.1'");
+    assert_eq!(locate(Some(&configured)), Some(configured.clone()));
+    assert_eq!(
+        locate(None),
+        which::which("ai-jail").ok(),
+        "without a setting, the PATH lookup"
+    );
+}
+
+// --- forward-port (v2, R7.4) -------------------------------------------------------------------
+//
+// Authored Red at Run (story 043 v2). `probe` reads `--version`, then `--help`; the flag is set only
+// when the help text lists `--forward-port`, and a failing `--help` clears it without a refusal.
+
+mod probe_forward_port_tests {
+    use gpui::TestAppContext;
+
+    use super::{script, v};
+    use crate::probe::probe;
+
+    fn jail(version: &str, help: &str) -> String {
+        format!("case \"$1\" in\n--version) echo 'ai-jail {version}' ;;\n--help) {help} ;;\nesac")
+    }
+
+    #[gpui::test]
+    async fn probe_sets_the_flag_when_help_lists_forward_port(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let help = "printf 'Usage: ai-jail [OPTIONS]\\n    --forward-port <PORT>          Expose the host port\\n'";
+        let path = script(tmp.path(), "ai-jail", &jail("2.7.0", help));
+        let binary = probe(&path, &cx.executor()).await.expect("probe");
+        assert_eq!(binary.version, v("2.7.0"));
+        assert!(
+            binary.supports_forward_port,
+            "the help lists --forward-port"
+        );
+    }
+
+    #[gpui::test]
+    async fn probe_clears_the_flag_on_the_real_2_8_1_help(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        // `/usr/bin/ai-jail --help` of 2.8.1, captured 2026-10-12: it has no --forward-port.
+        let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+            .join("test-data/ai-jail-2.8.1-help.txt");
+        let help = format!("cat '{}'", fixture.display());
+        let path = script(tmp.path(), "ai-jail", &jail("2.8.1", &help));
+        let binary = probe(&path, &cx.executor()).await.expect("probe");
+        assert_eq!(binary.version, v("2.8.1"));
+        assert!(
+            !binary.supports_forward_port,
+            "2.8.1's help does not list --forward-port"
+        );
+    }
+
+    #[gpui::test]
+    async fn probe_clears_the_flag_when_help_fails_without_refusing(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let help = "echo '    --forward-port <PORT>'; exit 2";
+        let path = script(tmp.path(), "ai-jail", &jail("2.7.0", help));
+        let binary = probe(&path, &cx.executor())
+            .await
+            .expect("a failing --help is not a refusal");
+        assert_eq!(binary.version, v("2.7.0"));
+        assert!(!binary.supports_forward_port, "a failing --help clears it");
+    }
+}
+
+// --- tech review fixes ------------------------------------------------------------------------------
+//
+// Recorded in the story's deviations.yaml (2.2, "probe cache key and child hygiene").
+
+mod review_fixes {
+    use std::time::{Duration, SystemTime};
+
+    use gpui::TestAppContext;
+
+    use super::{script, set_mtime, v};
+    use crate::probe::{probe, version};
+
+    fn jail(version: &str, help: &str) -> String {
+        format!("case \"$1\" in\n--version) echo 'ai-jail {version}' ;;\n--help) {help} ;;\nesac")
+    }
+
+    #[gpui::test]
+    async fn a_symlink_retargeted_under_an_identical_mtime_is_re_read(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1);
+        let old = script(tmp.path(), "old-ai-jail", "echo 'ai-jail 2.8.1'");
+        let new = script(tmp.path(), "new-ai-jail", "echo 'ai-jail 2.3.9'");
+        set_mtime(&old, t0);
+        set_mtime(&new, t0);
+        let link = tmp.path().join("ai-jail");
+        std::os::unix::fs::symlink(&old, &link).expect("symlink");
+        assert_eq!(
+            version(&link, &cx.executor()).await.expect("old"),
+            v("2.8.1")
+        );
+
+        std::fs::remove_file(&link).expect("unlink");
+        std::os::unix::fs::symlink(&new, &link).expect("retarget");
+        assert_eq!(
+            version(&link, &cx.executor()).await.expect("new"),
+            v("2.3.9"),
+            "a profile switch under mtime=1 must not keep the old version (it hides the <2.4.0 warning)"
+        );
+    }
+
+    #[gpui::test]
+    async fn only_the_option_column_counts_as_listing_forward_port(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        for (name, line, expected) in [
+            (
+                "prose",
+                "    --allow-tcp-port <PORT> Deprecated; there is no --forward-port in this build",
+                false,
+            ),
+            (
+                "other-option",
+                "    --network  cannot combine with --allow-host, --forward-port",
+                false,
+            ),
+            (
+                "option",
+                "    --forward-port <PORT>  Forward a host port",
+                true,
+            ),
+            (
+                "longer-name",
+                "    --forward-ports <PORTS>  Forward host ports",
+                false,
+            ),
+            (
+                "short-flag",
+                "    -f, --forward-port=PORT  Forward a host port",
+                true,
+            ),
+        ] {
+            let help = format!("printf '%s\\n' 'Options:' '{line}'");
+            let path = script(tmp.path(), name, &jail("2.7.0", &help));
+            let binary = probe(&path, &cx.executor()).await.expect(name);
+            assert_eq!(binary.supports_forward_port, expected, "{name}: {line:?}");
+        }
+    }
+
+    #[gpui::test]
+    async fn a_briefly_busy_binary_is_waited_out(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let path = script(tmp.path(), "ai-jail", "echo 'ai-jail 2.8.1'");
+        // An open write handle makes exec fail with ETXTBSY, as a binary mid-upgrade (or a
+        // sibling test's fork) does. It is released after 50 ms; the probe must wait it out.
+        let writer = std::fs::File::options()
+            .append(true)
+            .open(&path)
+            .expect("open for write");
+        let release = std::thread::spawn(move || {
+            std::thread::sleep(Duration::from_millis(50));
+            drop(writer);
+        });
+        let result = version(&path, &cx.executor()).await;
+        release.join().expect("release thread");
+        assert_eq!(result.expect("waited out ETXTBSY"), v("2.8.1"));
+    }
+
+    #[gpui::test]
+    async fn at_most_64_kib_of_help_is_read(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        // 128 KiB of filler, then the flag; the child ignores SIGPIPE and exits 0, so only the
+        // read cap keeps the flag out of view.
+        let help = "trap '' PIPE; i=0; while [ $i -lt 2048 ]; do \
+                    echo '...............................................................'; \
+                    i=$((i+1)); done 2>/dev/null; echo '    --forward-port <PORT>' 2>/dev/null; exit 0";
+        let path = script(tmp.path(), "ai-jail", &jail("2.7.0", help));
+        let binary = probe(&path, &cx.executor()).await.expect("probe");
+        assert_eq!(binary.version, v("2.7.0"));
+        assert!(
+            !binary.supports_forward_port,
+            "a flag past the first 64 KiB of help is never read"
+        );
+    }
+
+    #[gpui::test]
+    async fn the_child_gets_only_path_and_home(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let dump = tmp.path().join("env");
+        let body = format!("env > '{}'\necho 'ai-jail 2.8.1'", dump.display());
+        let path = script(tmp.path(), "ai-jail", &body);
+        version(&path, &cx.executor()).await.expect("version");
+        let names: Vec<String> = std::fs::read_to_string(&dump)
+            .expect("env dump")
+            .lines()
+            .filter_map(|line| line.split_once('=').map(|(name, _)| name.to_string()))
+            .collect();
+        // PWD, SHLVL and `_` are what /bin/sh itself sets in an empty environment.
+        let allowed = ["PATH", "HOME", "PWD", "SHLVL", "_", "OLDPWD"];
+        for name in &names {
+            assert!(
+                allowed.contains(&name.as_str()),
+                "the probe child inherited {name}; only PATH and HOME are passed"
+            );
+        }
+        if std::env::var_os("PATH").is_some() {
+            assert!(names.iter().any(|name| name == "PATH"), "PATH is passed");
+        }
+        if std::env::var_os("HOME").is_some() {
+            assert!(names.iter().any(|name| name == "HOME"), "HOME is passed");
+        }
+    }
+    #[gpui::test]
+    async fn a_wrapped_description_line_starting_with_the_flag_does_not_count(
+        cx: &mut TestAppContext,
+    ) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let help = "printf '%s\\n' 'Options:' '    --network  Enable network access' \
+                    '                  --forward-port is incompatible with --network'";
+        let path = script(tmp.path(), "ai-jail", &jail("2.7.0", help));
+        let binary = probe(&path, &cx.executor()).await.expect("probe");
+        assert!(
+            !binary.supports_forward_port,
+            "a description line wrapped below the option column is prose"
+        );
+    }
+
+    #[gpui::test]
+    async fn the_option_column_is_read_from_the_real_help_texts(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+            .join("test-data/ai-jail-2.8.1-help.txt");
+        let real_2_8_1 = script(
+            tmp.path(),
+            "real-2.8.1",
+            &jail("2.8.1", &format!("cat '{}'", fixture.display())),
+        );
+        assert!(
+            !probe(&real_2_8_1, &cx.executor())
+                .await
+                .expect("2.8.1")
+                .supports_forward_port,
+            "2.8.1 wraps descriptions with flags at the line start, and has no --forward-port"
+        );
+
+        // The fork's 2.7.0 line, verbatim, among 2.8.1's options.
+        let fork_line = "    --forward-port <PORT>          Expose the host's 127.0.0.1:PORT on the sandbox's own";
+        let fork_help = tmp.path().join("fork-help.txt");
+        std::fs::write(&fork_help, format!("{fork_line}\n")).expect("write fork line");
+        let with_fork_line = script(
+            tmp.path(),
+            "fork-2.7.0",
+            &jail(
+                "2.7.0",
+                &format!("cat '{}' '{}'", fixture.display(), fork_help.display()),
+            ),
+        );
+        assert!(
+            probe(&with_fork_line, &cx.executor())
+                .await
+                .expect("2.7.0")
+                .supports_forward_port,
+            "the fork's --forward-port option line counts"
+        );
+    }
+
+    #[gpui::test]
+    async fn an_unparsable_first_line_is_truncated(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        // ~60 KiB without a newline, just under the read cap so the child exits 0 instead of
+        // dying on SIGPIPE; multi-byte, so a byte cut would split a character.
+        let body =
+            "awk 'BEGIN { printf \"a\"; for (i = 0; i < 30000; i++) printf \"\\303\\251\" }'";
+        let path = script(tmp.path(), "ai-jail", body);
+        let error = version(&path, &cx.executor())
+            .await
+            .expect_err("not a version");
+        let crate::probe::ProbeError::Unparsable { first_line, .. } = &error else {
+            panic!("expected Unparsable, got {error:?}");
+        };
+        assert!(first_line.starts_with("aé"), "{first_line:?}");
+        assert!(first_line.ends_with('…'), "marked as cut: {first_line:?}");
+        assert_eq!(
+            first_line.chars().count(),
+            121,
+            "120 characters and the ellipsis"
+        );
+        assert!(
+            error.to_string().len() < 512,
+            "the message stays short: {} bytes",
+            error.to_string().len()
+        );
+    }
+}
diff --git a/crates/agent_sandbox/src/project_keys.rs b/crates/agent_sandbox/src/project_keys.rs
new file mode 100644
index 0000000..01a3819
--- /dev/null
+++ b/crates/agent_sandbox/src/project_keys.rs
@@ -0,0 +1,115 @@
+//! R2.2: a project settings file carrying `agent_sandbox` or `agent_memory` gets one warning.
+//!
+//! The parsed project settings drop unknown keys without a trace, so the raw file is read.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use gpui::{App, AppContext as _, BorrowAppContext as _, Context, Global};
+use project::{Fs, MTime, PathChange, Project, UpdatedEntriesSet, WorktreeId};
+
+/// The top-level keys only the user settings file may carry.
+const USER_ONLY_KEYS: [&str; 2] = ["agent_sandbox", "agent_memory"];
+
+/// The mtime each settings file was last checked at, so an unchanged file never warns twice.
+/// One entry per file: a newer mtime replaces the older one.
+#[derive(Default)]
+struct CheckedProjectFiles(HashMap<PathBuf, Option<MTime>>);
+
+impl Global for CheckedProjectFiles {}
+
+pub(crate) fn init(cx: &mut App) {
+    cx.set_global(CheckedProjectFiles::default());
+    cx.observe_new(
+        |project: &mut Project, _window, cx: &mut Context<Project>| {
+            // A remote project's settings file lives on the remote host.
+            if !project.is_local() {
+                return;
+            }
+            cx.subscribe_self(|project: &mut Project, event: &project::Event, cx| {
+                if let project::Event::WorktreeUpdatedEntries(worktree_id, changes) = event {
+                    check_settings_files(project, *worktree_id, changes, cx);
+                }
+            })
+            .detach();
+        },
+    )
+    .detach();
+}
+
+fn check_settings_files(
+    project: &Project,
+    worktree_id: WorktreeId,
+    changes: &UpdatedEntriesSet,
+    cx: &mut Context<Project>,
+) {
+    let settings_file = paths::local_settings_file_relative_path();
+    let touched = changes
+        .iter()
+        .any(|(path, _, change)| path.as_ref() == settings_file && *change != PathChange::Removed);
+    if !touched {
+        return;
+    }
+    let Some(worktree) = project.worktree_for_id(worktree_id, cx) else {
+        return;
+    };
+    let worktree = worktree.read(cx);
+    let abs_path = worktree.absolutize(settings_file);
+    let mtime = worktree
+        .entry_for_path(settings_file)
+        .and_then(|entry| entry.mtime);
+    let first_check = cx.update_global(|checked: &mut CheckedProjectFiles, _| {
+        checked.0.insert(abs_path.clone(), mtime) != Some(mtime)
+    });
+    if !first_check {
+        return;
+    }
+    let fs: Arc<dyn Fs> = project.fs().clone();
+    cx.background_spawn(async move {
+        let text = match fs.load(&abs_path).await {
+            Ok(text) => text,
+            Err(error) => {
+                log::debug!(
+                    "agent_sandbox event=project_file_unreadable file={} error={error:#}",
+                    abs_path.display()
+                );
+                return;
+            }
+        };
+        let keys = match user_only_keys_in(&text) {
+            Ok(keys) => keys,
+            Err(error) => {
+                // A syntax error carries a position, never the text around it.
+                log::debug!(
+                    "agent_sandbox event=project_file_unparsed file={} error={error:#}",
+                    abs_path.display()
+                );
+                return;
+            }
+        };
+        if keys.is_empty() {
+            return;
+        }
+        // The file and the key names only: a value from the file is never logged.
+        log::warn!(
+            "agent_sandbox event=project_key_ignored file={} keys={} reason=\"user settings only\"",
+            abs_path.display(),
+            keys.join(",")
+        );
+    })
+    .detach();
+}
+
+/// The user-only keys present at the top level of a JSONC settings file, in a fixed order.
+fn user_only_keys_in(text: &str) -> anyhow::Result<Vec<&'static str>> {
+    let serde_json::Value::Object(object) =
+        settings::parse_json_with_comments::<serde_json::Value>(text)?
+    else {
+        return Ok(Vec::new());
+    };
+    Ok(USER_ONLY_KEYS
+        .into_iter()
+        .filter(|key| object.contains_key(*key))
+        .collect())
+}
diff --git a/crates/agent_sandbox/src/roundtrip_tests.rs b/crates/agent_sandbox/src/roundtrip_tests.rs
new file mode 100644
index 0000000..3cebda1
--- /dev/null
+++ b/crates/agent_sandbox/src/roundtrip_tests.rs
@@ -0,0 +1,313 @@
+// Story 043, task 9.1 — both ends of every wire: the page's writer → the settings file → the
+// reloaded settings → `decide` → the launch registry → drift (R1.1, R1.12, R3.2, R3.8, R5.2,
+// R5.4). Wired in the crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod roundtrip_tests;
+//
+// Validation filter: `roundtrip` (every test path is `roundtrip_tests::…`).
+//
+// The icon's tooltip text from a registry record is asserted in agent_ui's
+// `agent_jail_status_tests` (it cannot be reached from this crate); this file proves that the
+// record the icon and the page read is exactly the one `decide` produced, keyed exactly.
+//
+// Contract (design.md §launch, §Confirmation, §Data Models). Assumed signatures — adjust the
+// `use`/call only:
+//   - `crate::confirm::set_project_enabled(root, enabled, window, cx) -> Task<_>` (the page's and
+//     the palette's only writer of the switch)
+//   - `crate::launch::{decide, drifted, LaunchDecision, LaunchRecord, Reason}` as in launch_tests
+//   - `crate::launch::LaunchRegistry::register(record: LaunchRecord, cx: &mut App)` and
+//     `LaunchRegistry::get(agent_id: &AgentId, root: &Path, cx: &App) -> Option<LaunchRecord>`
+//
+// Hostile halves first:
+//   - registry key, wrongly COLLAPSE: the record for (agent, root) is not returned for another
+//     root nor for another agent of the same root.
+//   - written shape, wrongly SPLIT: the file the writer produced reloads into the same policy that
+//     drives the argv (`projects["<absolute root>"]`), not a parallel key nobody reads.
+//   - R1.12: after the switch goes off, the registry still holds the record the agent started
+//     with, and drift reports it.
+
+use std::os::unix::fs::PermissionsExt as _;
+use std::path::PathBuf;
+
+use fs::{FakeFs, Fs, RealFs};
+use gpui::{TestAppContext, UpdateGlobal as _};
+use project::Project;
+use project::agent_server_store::{AgentId, AgentServerCommand};
+use serde_json::json;
+use settings::{Settings as _, SettingsStore};
+
+use crate::AgentSandboxSettings;
+use crate::confirm::set_project_enabled;
+use crate::launch::{LaunchDecision, LaunchRegistry, Reason, decide, drifted};
+
+fn reload_from(text: &str, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(text, cx)
+                .result()
+                .expect("the written file parses back");
+        })
+    });
+    cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn roundtrip_page_write_to_launch_to_registry_to_drift(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = tmp.path().canonicalize().expect("canonical");
+    let root = base.join("app");
+    let other_root = base.join("app2");
+    std::fs::create_dir_all(&root).expect("project");
+    std::fs::create_dir_all(&other_root).expect("other project");
+    let ai_jail = base.join("bin/ai-jail");
+    std::fs::create_dir_all(ai_jail.parent().expect("bin")).expect("bin");
+    std::fs::write(&ai_jail, "#!/bin/sh\necho 'ai-jail 2.8.1'\n").expect("script");
+    std::fs::set_permissions(&ai_jail, std::fs::Permissions::from_mode(0o755)).expect("chmod");
+    let claude_dir = base.join("claude-jailed");
+    std::fs::create_dir_all(&claude_dir).expect("claude dir");
+
+    // The user file before the page touches it: sandbox off, a project host already listed.
+    let user = json!({
+        "agent_sandbox": {
+            "enabled": false,
+            "ai_jail_path": ai_jail,
+            "claude_config_dir": claude_dir,
+            "allow_hosts": ["anthropic.com"],
+            "projects": { root.display().to_string(): { "allow_hosts": ["registry.npmjs.org"] } }
+        },
+        "agent_memory": { "enabled": false }
+    });
+    let settings_fs = FakeFs::new(cx.executor());
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        <dyn Fs>::set_global(settings_fs.clone(), cx);
+        crate::init(cx);
+    });
+    // The fake fs needs the settings file's parent before the writer can save into it.
+    settings_fs
+        .create_dir(paths::config_dir())
+        .await
+        .expect("config dir");
+    settings_fs
+        .insert_file(paths::settings_file(), user.to_string().into_bytes())
+        .await;
+    reload_from(&user.to_string(), cx);
+
+    // 1. The page's writer turns the project on (no prompt for on).
+    {
+        let vcx = cx.add_empty_window();
+        let task = vcx.update(|window, cx| set_project_enabled(&root, true, window, cx));
+        vcx.run_until_parked();
+        let _ = task.await;
+    }
+    let written = settings_fs
+        .load(paths::settings_file())
+        .await
+        .expect("file");
+    reload_from(&written, cx);
+    assert!(cx.update(|cx| AgentSandboxSettings::get_global(cx).effective_enabled(&root)));
+    assert!(
+        !cx.update(|cx| AgentSandboxSettings::get_global(cx).effective_enabled(&other_root)),
+        "the write is keyed by this root only"
+    );
+
+    // 2. The reloaded settings drive decide to the expected argv.
+    let project = Project::test(RealFs::new(None, cx.executor()), [root.as_path()], cx).await;
+    cx.run_until_parked();
+    let agent = AgentId::new("claude-acp");
+    let cmd = AgentServerCommand {
+        path: PathBuf::from("/usr/bin/claude-agent-acp-plus"),
+        args: vec![],
+        env: None,
+    };
+    let decision = {
+        let mut async_cx = cx.to_async();
+        // The agent's settings configure no variable: nothing is forwarded into the jail.
+        let configured_env = std::collections::HashSet::new();
+        decide(agent.clone(), &project, cmd, &configured_env, &mut async_cx).await
+    };
+    let (record, jailed) = match decision {
+        LaunchDecision::Jailed(record, jailed) => (record, jailed),
+        LaunchDecision::Unchanged(_) => panic!("expected Jailed, got Unchanged"),
+        LaunchDecision::Refused(error) => panic!("expected Jailed, got Refused: {error}"),
+    };
+    let hosts: Vec<&str> = jailed
+        .args
+        .windows(2)
+        .filter(|pair| pair[0] == "--allow-host")
+        .map(|pair| pair[1].as_str())
+        .collect();
+    assert_eq!(hosts, vec!["anthropic.com", "registry.npmjs.org"]);
+    assert_eq!(
+        record.allow_hosts,
+        vec![
+            "anthropic.com".to_string(),
+            "registry.npmjs.org".to_string()
+        ]
+    );
+    assert!(matches!(record.reason, Reason::On));
+
+    // 3. The registry returns exactly that record, for exactly that key.
+    cx.update(|cx| LaunchRegistry::register(record.clone(), cx));
+    cx.update(|cx| {
+        let stored = LaunchRegistry::get(&agent, &root, cx).expect("registered record");
+        assert!(matches!(stored.reason, Reason::On));
+        assert_eq!(stored.project_root, root);
+        assert_eq!(stored.ai_jail_version, record.ai_jail_version);
+        assert_eq!(stored.rw_maps, record.rw_maps);
+        assert_eq!(stored.allow_hosts, record.allow_hosts);
+        assert_eq!(stored.forward_ports, record.forward_ports);
+        assert!(
+            LaunchRegistry::get(&agent, &other_root, cx).is_none(),
+            "another root"
+        );
+        assert!(
+            LaunchRegistry::get(&AgentId::new("codex-acp"), &root, cx).is_none(),
+            "another agent of the same root"
+        );
+        assert!(
+            !drifted(&stored, AgentSandboxSettings::get_global(cx)),
+            "no drift yet"
+        );
+    });
+
+    // 4. The switch goes off through the confirmed writer; the running agent keeps its record.
+    {
+        let vcx = cx.add_empty_window();
+        let task = vcx.update(|window, cx| set_project_enabled(&root, false, window, cx));
+        vcx.run_until_parked();
+        vcx.simulate_prompt_answer("Turn Off");
+        let _ = task.await;
+    }
+    let written = settings_fs
+        .load(paths::settings_file())
+        .await
+        .expect("file");
+    reload_from(&written, cx);
+    cx.update(|cx| {
+        let stored = LaunchRegistry::get(&agent, &root, cx).expect("still registered");
+        assert!(
+            matches!(stored.reason, Reason::On),
+            "R1.12: the started state is kept"
+        );
+        assert!(
+            drifted(&stored, AgentSandboxSettings::get_global(cx)),
+            "and reported as drift"
+        );
+    });
+}
+
+// Added by the 9.1 run (not pre-authored): the shapes the icon (4.1), the page (5.2, 7.1) and the
+// palette (8.1) read, held by exhaustive literals and patterns — no `..` — so a field added to or
+// removed from a producer fails this compile until a consumer has been checked for it. The parsed
+// values are asserted from the captured ai-memory fixtures in `memory_tests`; the rendered text in
+// agent_ui's and settings_ui's own tests.
+#[gpui::test]
+fn roundtrip_contract_shapes_carry_every_field_the_consumers_read(cx: &mut TestAppContext) {
+    use crate::launch::{LaunchRecord, MemoryPort, drifted_now};
+    use crate::memory::{MemoryScope, MemoryStatus, RecentPage, ResolvedBy, display_endpoint};
+
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        crate::init(cx);
+    });
+
+    let root = PathBuf::from("/work/app");
+    let record = LaunchRecord {
+        agent_id: AgentId::new("claude-agent-acp-plus"),
+        project_root: root.clone(),
+        reason: Reason::On,
+        ai_jail_version: Some(semver::Version::new(2, 8, 1)),
+        ai_jail_path: Some(PathBuf::from("/usr/bin/ai-jail")),
+        rw_maps: vec![PathBuf::from("/home/u/.claude-jailed")],
+        allow_hosts: vec!["anthropic.com".to_string()],
+        forward_ports: vec![49374],
+        memory_port: MemoryPort::Forwarded(49374),
+        settings_fingerprint: 1,
+        memory_fingerprint: 2,
+        settings_key: Some("/work/app".to_string()),
+    };
+    cx.update(|cx| {
+        LaunchRegistry::register(record.clone(), cx);
+        let stored = LaunchRegistry::get(&record.agent_id, &root, cx).expect("registered");
+        assert_eq!(
+            stored, record,
+            "the registry keeps every field the launch wrote"
+        );
+        assert_eq!(LaunchRegistry::for_root(&root, cx), vec![record.clone()]);
+        // The icon's inputs (agent_jail_status::jail_status_summary) and the page's drift line.
+        let LaunchRecord {
+            agent_id: _,
+            project_root: _,
+            reason,
+            ai_jail_version,
+            ai_jail_path: _,
+            rw_maps,
+            allow_hosts,
+            forward_ports,
+            memory_port,
+            settings_fingerprint: _,
+            memory_fingerprint: _,
+            settings_key: _,
+        } = &stored;
+        assert_eq!(*reason, Reason::On);
+        assert_eq!(
+            ai_jail_version.as_ref().map(ToString::to_string).as_deref(),
+            Some("2.8.1")
+        );
+        assert_eq!(rw_maps.len(), 1);
+        assert_eq!(allow_hosts.len(), 1);
+        assert_eq!(forward_ports, &vec![49374]);
+        assert_eq!(*memory_port, MemoryPort::Forwarded(49374));
+        // The fingerprints are not those of the settings of now: drift is reported, not hidden.
+        assert!(drifted_now(&stored, cx));
+    });
+
+    // 7.1's status line: version, shown endpoint, scope, pages, sessions; its list: title, path.
+    let status = MemoryStatus {
+        server_version: "2.6.3".to_string(),
+        endpoint: url::Url::parse("http://user:secret@127.0.0.1:49374/mcp?token=t#f").expect("url"),
+        scope: MemoryScope {
+            workspace: "default".to_string(),
+            project: "zeo-workspace-zed-workspace".to_string(),
+            resolved_by: ResolvedBy::Marker,
+        },
+        pages: 77,
+        sessions: 129,
+    };
+    let MemoryStatus {
+        server_version,
+        endpoint,
+        scope,
+        pages,
+        sessions,
+    } = &status;
+    let line = format!(
+        "ai-memory {} at {} · {}: {} pages, {} sessions",
+        server_version,
+        display_endpoint(endpoint),
+        scope,
+        pages,
+        sessions
+    );
+    assert_eq!(
+        line,
+        "ai-memory 2.6.3 at http://127.0.0.1:49374/mcp · default/zeo-workspace-zed-workspace: \
+         77 pages, 129 sessions"
+    );
+    let page = RecentPage {
+        title: "FS gotcha".to_string(),
+        path: "gotchas/fs.md".to_string(),
+    };
+    let RecentPage { title, path } = &page;
+    assert_eq!(
+        (title.as_str(), path.as_str()),
+        ("FS gotcha", "gotchas/fs.md")
+    );
+}
diff --git a/crates/agent_sandbox/src/scope_tests.rs b/crates/agent_sandbox/src/scope_tests.rs
new file mode 100644
index 0000000..104c0e1
--- /dev/null
+++ b/crates/agent_sandbox/src/scope_tests.rs
@@ -0,0 +1,358 @@
+// Story 043, task 6.1 — ai-memory scope resolution (R6.7). Wired in the crate root
+// `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod scope_tests;
+//
+// Validation filter: `scope` (every test path is `scope_tests::…`).
+//
+// Contract (design.md §agent_sandbox::memory). Assumed signatures — adjust the `use`/call only:
+//   - `crate::memory::resolve_scope(root: &Path, fs: Arc<dyn Fs>) -> MemoryScope` (async; if it
+//     is sync or takes `&dyn Fs`, drop the `.await`/borrow — the assertions stay)
+//   - `MemoryScope { workspace: String, project: String, resolved_by: ResolvedBy }`
+//   - `ResolvedBy::{Marker, Upstream, Origin, Folder}`
+//
+// Repositories are real (`git init` + `git remote add`, test-only subprocesses) and read through
+// `RealFs`, so the remote lookup goes through the same git layer Zeo uses.
+//
+// Hostile halves first:
+//   - marker, wrongly FOUND: a marker in a sibling directory or below the root is not "at or
+//     above"; a malformed marker falls through instead of being trusted.
+//   - project name, wrongly SPLIT: the ssh, scp-like and https spellings of one repository give
+//     one project; wrongly COLLAPSE: `zeo-workspace/zed` and `zeo-workspace/zed-workspace` stay
+//     two; `.git` is dropped only as a suffix (`b.github.io` keeps its dots).
+//   - remote order: upstream wins over origin even when origin is added first.
+//
+// Known residue of the rule itself (reported, not asserted different): `a-b/c` and `a/b-c` both
+// render `a-b-c` — R6.7's `/`→`-` mapping makes them one scope by construction.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::Arc;
+
+use fs::{Fs, RealFs};
+use gpui::TestAppContext;
+
+use crate::memory::{MemoryScope, ResolvedBy, resolve_scope};
+
+#[allow(clippy::disallowed_methods)]
+fn git(dir: &Path, args: &[&str]) {
+    let status = Command::new("git")
+        .args(args)
+        .current_dir(dir)
+        .env("GIT_CONFIG_GLOBAL", "/dev/null")
+        .env("GIT_CONFIG_NOSYSTEM", "1")
+        .status()
+        .expect("git runs");
+    assert!(status.success(), "git {args:?} in {}", dir.display());
+}
+
+fn repo(base: &Path, name: &str, remotes: &[(&str, &str)]) -> PathBuf {
+    let dir = base.join(name);
+    std::fs::create_dir_all(&dir).expect("mkdir");
+    git(&dir, &["init", "-q"]);
+    for (remote, url) in remotes {
+        git(&dir, &["remote", "add", remote, url]);
+    }
+    dir
+}
+
+async fn scope(root: &Path, cx: &mut TestAppContext) -> MemoryScope {
+    let fs: Arc<dyn Fs> = RealFs::new(None, cx.executor());
+    resolve_scope(root, fs).await
+}
+
+fn base(tmp: &tempfile::TempDir) -> PathBuf {
+    tmp.path().canonicalize().expect("canonical")
+}
+
+// --- marker -----------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn scope_marker_in_sibling_or_below_is_ignored(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = base(&tmp);
+    let root = repo(&base, "w/app", &[("origin", "git@github.com:acme/app.git")]);
+    std::fs::create_dir_all(base.join("w/sibling")).expect("mkdir");
+    std::fs::write(
+        base.join("w/sibling/.ai-memory.toml"),
+        "workspace = \"wrong\"\nproject = \"wrong\"\n",
+    )
+    .expect("write");
+    std::fs::create_dir_all(root.join("sub")).expect("mkdir");
+    std::fs::write(
+        root.join("sub/.ai-memory.toml"),
+        "workspace = \"below\"\nproject = \"below\"\n",
+    )
+    .expect("write");
+    let resolved = scope(&root, cx).await;
+    assert_eq!(resolved.project, "acme-app");
+    assert_eq!(resolved.workspace, "default");
+    assert!(matches!(resolved.resolved_by, ResolvedBy::Origin));
+}
+
+#[gpui::test]
+async fn scope_malformed_marker_falls_through(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = base(&tmp);
+    let root = repo(
+        &base,
+        "app",
+        &[("origin", "https://github.com/acme/app.git")],
+    );
+    std::fs::write(root.join(".ai-memory.toml"), "workspace = = broken [").expect("write");
+    let resolved = scope(&root, cx).await;
+    assert!(
+        matches!(resolved.resolved_by, ResolvedBy::Origin),
+        "a broken marker is not trusted"
+    );
+    assert_eq!(resolved.project, "acme-app");
+}
+
+#[gpui::test]
+async fn scope_marker_at_or_above_the_root_wins(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = base(&tmp);
+    let root = repo(
+        &base,
+        "w/app",
+        &[("upstream", "git@github.com:acme/app.git")],
+    );
+    std::fs::write(
+        base.join("w/.ai-memory.toml"),
+        "workspace = \"team\"\nproject = \"shared\"\n",
+    )
+    .expect("write");
+    let above = scope(&root, cx).await;
+    assert_eq!(
+        (above.workspace.as_str(), above.project.as_str()),
+        ("team", "shared")
+    );
+    assert!(matches!(above.resolved_by, ResolvedBy::Marker));
+
+    std::fs::write(root.join(".ai-memory.toml"), "project = \"own\"\n").expect("write");
+    let nearest = scope(&root, cx).await;
+    assert_eq!(nearest.project, "own", "the nearest marker wins");
+    assert_eq!(
+        nearest.workspace, "default",
+        "a marker without workspace → default"
+    );
+}
+
+// --- remotes ----------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn scope_upstream_wins_over_origin(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = base(&tmp);
+    let root = repo(
+        &base,
+        "fork",
+        &[
+            ("origin", "git@github.com:me/zed.git"),
+            ("upstream", "git@github.com:zed-industries/zed.git"),
+        ],
+    );
+    let resolved = scope(&root, cx).await;
+    assert_eq!(resolved.project, "zed-industries-zed");
+    assert!(matches!(resolved.resolved_by, ResolvedBy::Upstream));
+}
+
+#[gpui::test]
+async fn scope_spellings_of_one_repo_agree_and_near_names_differ(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = base(&tmp);
+    for (name, url, expected) in [
+        (
+            "near",
+            "git@github.com:zeo-workspace/zed.git",
+            "zeo-workspace-zed",
+        ),
+        (
+            "scp",
+            "git@github.com:zeo-workspace/zed-workspace.git",
+            "zeo-workspace-zed-workspace",
+        ),
+        (
+            "https",
+            "https://github.com/zeo-workspace/zed-workspace",
+            "zeo-workspace-zed-workspace",
+        ),
+        (
+            "https-git",
+            "https://github.com/zeo-workspace/zed-workspace.git",
+            "zeo-workspace-zed-workspace",
+        ),
+        (
+            "ssh",
+            "ssh://git@github.com/zeo-workspace/zed-workspace.git",
+            "zeo-workspace-zed-workspace",
+        ),
+        (
+            "dots",
+            "https://github.com/acme/acme.github.io.git",
+            "acme-acme.github.io",
+        ),
+        (
+            "nested",
+            "https://gitlab.com/group/sub/repo.git",
+            "group-sub-repo",
+        ),
+    ] {
+        let root = repo(&base, name, &[("origin", url)]);
+        let resolved = scope(&root, cx).await;
+        assert_eq!(resolved.project, expected, "{url}");
+        assert_eq!(resolved.workspace, "default", "{url}");
+    }
+}
+
+// --- folder fallback ----------------------------------------------------------------------------
+
+#[gpui::test]
+async fn scope_falls_back_to_the_folder_name(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = base(&tmp);
+    let plain = base.join("my-notes");
+    std::fs::create_dir_all(&plain).expect("mkdir");
+    let resolved = scope(&plain, cx).await;
+    assert_eq!(resolved.project, "my-notes");
+    assert_eq!(resolved.workspace, "default");
+    assert!(matches!(resolved.resolved_by, ResolvedBy::Folder));
+
+    let no_remote = repo(&base, "local-only", &[]);
+    let resolved = scope(&no_remote, cx).await;
+    assert_eq!(
+        resolved.project, "local-only",
+        "a repository without remotes → folder name"
+    );
+    assert!(matches!(resolved.resolved_by, ResolvedBy::Folder));
+}
+
+// --- review fixes -------------------------------------------------------------------------------
+
+mod review_fixes {
+    use std::time::{Duration, Instant};
+
+    use super::*;
+    use crate::test_log_capture::{LogCapture, kv};
+
+    #[allow(clippy::disallowed_methods)]
+    fn mkfifo(path: &Path) {
+        let status = Command::new("mkfifo")
+            .arg(path)
+            .status()
+            .expect("mkfifo runs");
+        assert!(status.success(), "mkfifo {}", path.display());
+    }
+
+    fn invalid_marker_reasons(logs: &LogCapture) -> Vec<(String, String)> {
+        logs.matching("event=marker_invalid")
+            .into_iter()
+            .filter(|(level, _)| *level == log::Level::Warn)
+            .map(|(_, line)| {
+                let fields = kv(&line);
+                (
+                    fields.get("file").cloned().unwrap_or_default(),
+                    fields.get("reason").cloned().unwrap_or_default(),
+                )
+            })
+            .collect()
+    }
+
+    // M1: a hostile clone can ship `.ai-memory.toml -> /dev/zero` (a character device reporting
+    // length 0) or a FIFO. Neither may be read; resolution falls through to the remote, quickly.
+    #[gpui::test]
+    async fn scope_marker_that_is_a_device_or_a_fifo_is_skipped(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let logs = LogCapture::start();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let base = base(&tmp);
+        let root = repo(&base, "w/app", &[("origin", "git@github.com:acme/app.git")]);
+        let device = root.join(".ai-memory.toml");
+        std::os::unix::fs::symlink("/dev/zero", &device).expect("symlink");
+        let fifo = base.join("w/.ai-memory.toml");
+        mkfifo(&fifo);
+
+        let started = Instant::now();
+        let resolved = scope(&root, cx).await;
+        assert!(
+            started.elapsed() < Duration::from_secs(10),
+            "resolution must not read a device or block on a FIFO"
+        );
+        assert_eq!(resolved.project, "acme-app");
+        assert_eq!(resolved.workspace, "default");
+        assert!(matches!(resolved.resolved_by, ResolvedBy::Origin));
+
+        let reasons = invalid_marker_reasons(&logs);
+        assert_eq!(
+            reasons,
+            vec![
+                (format!("{device:?}"), "not_a_regular_file".to_string()),
+                (format!("{fifo:?}"), "not_a_regular_file".to_string()),
+            ],
+            "one quoted warn line per skipped marker"
+        );
+    }
+
+    #[gpui::test]
+    async fn scope_marker_over_the_size_limit_is_skipped(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let logs = LogCapture::start();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let base = base(&tmp);
+        let root = repo(
+            &base,
+            "app",
+            &[("origin", "https://github.com/acme/app.git")],
+        );
+        let marker = root.join(".ai-memory.toml");
+        let mut text = "project = \"huge\"\n".to_string();
+        text.push_str(&"#".repeat(64 * 1024));
+        std::fs::write(&marker, text).expect("write");
+
+        let resolved = scope(&root, cx).await;
+        assert_eq!(resolved.project, "acme-app");
+        assert_eq!(
+            invalid_marker_reasons(&logs),
+            vec![(format!("{marker:?}"), "too_large".to_string())]
+        );
+    }
+
+    // L2: log values are quoted (Q14: one key=value line even for a path with spaces).
+    #[gpui::test]
+    async fn scope_log_values_are_quoted(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let logs = LogCapture::start();
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let base = base(&tmp);
+        let root = base.join("my notes");
+        std::fs::create_dir_all(&root).expect("mkdir");
+        std::fs::write(root.join(".ai-memory.toml"), "workspace = [").expect("write");
+
+        let resolved = scope(&root, cx).await;
+        assert_eq!(resolved.project, "my notes");
+        let resolved_lines = logs.matching("event=scope_resolved");
+        assert_eq!(resolved_lines.len(), 1);
+        assert!(
+            resolved_lines[0]
+                .1
+                .contains("workspace=\"default\" project=\"my notes\""),
+            "{}",
+            resolved_lines[0].1
+        );
+        let invalid = logs.matching("event=marker_invalid");
+        assert_eq!(invalid.len(), 1);
+        let marker = root.join(".ai-memory.toml");
+        assert!(
+            invalid[0].1.contains(&format!("file={marker:?}")),
+            "{}",
+            invalid[0].1
+        );
+    }
+}
diff --git a/crates/agent_sandbox/src/settings.rs b/crates/agent_sandbox/src/settings.rs
new file mode 100644
index 0000000..31dc0ef
--- /dev/null
+++ b/crates/agent_sandbox/src/settings.rs
@@ -0,0 +1,205 @@
+//! The `agent_sandbox` and `agent_memory` settings, read from the user settings file only.
+
+use std::collections::BTreeMap;
+use std::path::{Component, Path, PathBuf};
+
+use settings::{RegisterSetting, Settings, SettingsContent};
+
+use crate::validate::expand_tilde;
+
+/// `agent_sandbox.claude_config_dir` when the defaults lack it; `default.json` says the same.
+const DEFAULT_CLAUDE_CONFIG_DIR: &str = "~/.claude-jailed";
+/// `agent_sandbox.allow_hosts` when the defaults lack it: the measured login minimum.
+const DEFAULT_ALLOW_HOSTS: [&str; 2] = ["anthropic.com", "platform.claude.com"];
+/// `agent_memory.endpoint` when the defaults lack it.
+const DEFAULT_MEMORY_ENDPOINT: &str = "http://127.0.0.1:49374/mcp";
+
+/// The `agent_sandbox` settings section.
+#[derive(Clone, Debug, PartialEq, Eq, RegisterSetting)]
+pub struct AgentSandboxSettings {
+    /// The switch for projects without an entry in `projects`. Default: `false`.
+    pub enabled: bool,
+    /// The ai-jail binary; `None` looks it up on `PATH`.
+    pub ai_jail_path: Option<PathBuf>,
+    /// The Claude config directory mapped into the jail, as written (`~` not expanded).
+    pub claude_config_dir: String,
+    /// Writable folders every jailed project gets, as written.
+    pub rw_maps: Vec<String>,
+    /// Hosts every jailed project may reach, as written.
+    pub allow_hosts: Vec<String>,
+    /// Per-project overrides, keyed by the absolute root path as written.
+    pub projects: BTreeMap<String, AgentSandboxProjectSettings>,
+}
+
+/// One project's entry in `agent_sandbox.projects`.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct AgentSandboxProjectSettings {
+    /// `None` follows `agent_sandbox.enabled`.
+    pub enabled: Option<bool>,
+    pub rw_maps: Vec<String>,
+    pub allow_hosts: Vec<String>,
+}
+
+/// The defaults merged with one project's entry, unvalidated.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct ProjectPolicy {
+    pub enabled: bool,
+    /// The default folders, then the project's own, each once, in the configured order.
+    pub rw_maps: Vec<String>,
+    /// The default hosts, then the project's own, each once, in the configured order.
+    pub allow_hosts: Vec<String>,
+}
+
+impl AgentSandboxSettings {
+    /// The policy for the project rooted at `root`, keyed by [`lexical_match`]: neither a string
+    /// prefix (`/w/app` for `/w/app2`) nor a parent (`/w/app` for `/w/app/sub`) applies. Reads no
+    /// file system; a key spelled through a symlink is found by [`resolve_project_key`] instead.
+    pub fn project_policy(&self, root: &Path) -> ProjectPolicy {
+        self.policy_for_key(lexical_match(self.project_keys(), root))
+    }
+
+    /// The defaults merged with the entry under `key`; `None` (or a key without an entry) gives
+    /// the defaults alone.
+    pub fn policy_for_key(&self, key: Option<&str>) -> ProjectPolicy {
+        let Some(entry) = key.and_then(|key| self.projects.get(key)) else {
+            return ProjectPolicy {
+                enabled: self.enabled,
+                rw_maps: dedup_in_order(&self.rw_maps, &[]),
+                allow_hosts: dedup_in_order(&self.allow_hosts, &[]),
+            };
+        };
+        ProjectPolicy {
+            enabled: entry.enabled.unwrap_or(self.enabled),
+            rw_maps: dedup_in_order(&self.rw_maps, &entry.rw_maps),
+            allow_hosts: dedup_in_order(&self.allow_hosts, &entry.allow_hosts),
+        }
+    }
+
+    /// Whether the project rooted at `root` runs its agent jailed, keyed as [`Self::project_policy`].
+    pub fn effective_enabled(&self, root: &Path) -> bool {
+        lexical_match(self.project_keys(), root)
+            .and_then(|key| self.projects.get(key))
+            .and_then(|entry| entry.enabled)
+            .unwrap_or(self.enabled)
+    }
+
+    /// The keys of `projects`, in sorted order.
+    pub fn project_keys(&self) -> impl Iterator<Item = &str> {
+        self.projects.keys().map(String::as_str)
+    }
+}
+
+/// A `projects` key as a path: `~` expanded, `.` components and a trailing `/` dropped. `..` is
+/// kept: resolving it lexically would be wrong across a symlink.
+fn lexical_key(key: &str) -> PathBuf {
+    normalize(&expand_tilde(key, paths::home_dir()))
+}
+
+fn normalize(path: &Path) -> PathBuf {
+    path.components()
+        .filter(|component| !matches!(component, Component::CurDir))
+        .collect()
+}
+
+/// The `projects` key for `root` without touching the file system: the key spelled exactly as the
+/// root if there is one, else the smallest key equal to it after `~` expansion and dropping `.`
+/// components and a trailing `/`.
+pub fn lexical_match<'a>(keys: impl IntoIterator<Item = &'a str>, root: &Path) -> Option<&'a str> {
+    let exact = root.to_str();
+    let normalized_root = normalize(root);
+    let mut lexical: Option<&'a str> = None;
+    for key in keys {
+        if Some(key) == exact {
+            return Some(key);
+        }
+        if lexical.is_none_or(|best| key < best) && lexical_key(key) == normalized_root {
+            lexical = Some(key);
+        }
+    }
+    lexical
+}
+
+/// [`lexical_match`], else the smallest key that resolves to the same folder as `root` (a key or a
+/// root spelled through a symlink). Blocking: it canonicalizes every key, so call it on a
+/// background thread.
+pub fn resolve_project_key<'a>(
+    keys: impl IntoIterator<Item = &'a str>,
+    root: &Path,
+) -> Option<&'a str> {
+    let keys: Vec<&'a str> = keys.into_iter().collect();
+    if let Some(key) = lexical_match(keys.iter().copied(), root) {
+        return Some(key);
+    }
+    let canonical_root = root.canonicalize().ok()?;
+    keys.into_iter()
+        .filter(|key| {
+            lexical_key(key)
+                .canonicalize()
+                .is_ok_and(|canonical| canonical == canonical_root)
+        })
+        .min()
+}
+
+/// `defaults` then `own`, keeping the first occurrence of each entry.
+fn dedup_in_order(defaults: &[String], own: &[String]) -> Vec<String> {
+    let mut merged: Vec<String> = Vec::with_capacity(defaults.len() + own.len());
+    for entry in defaults.iter().chain(own) {
+        if !merged.contains(entry) {
+            merged.push(entry.clone());
+        }
+    }
+    merged
+}
+
+impl Settings for AgentSandboxSettings {
+    fn from_settings(content: &SettingsContent) -> Self {
+        // `default.json` carries every default below; the fallbacks keep them when an
+        // embedder's defaults lack the section.
+        let sandbox = content.agent_sandbox.clone().unwrap_or_default();
+        Self {
+            enabled: sandbox.enabled.unwrap_or(false),
+            ai_jail_path: sandbox.ai_jail_path,
+            claude_config_dir: sandbox
+                .claude_config_dir
+                .unwrap_or_else(|| DEFAULT_CLAUDE_CONFIG_DIR.to_string()),
+            rw_maps: sandbox.rw_maps.unwrap_or_default(),
+            allow_hosts: sandbox
+                .allow_hosts
+                .unwrap_or_else(|| DEFAULT_ALLOW_HOSTS.map(str::to_string).to_vec()),
+            projects: sandbox
+                .projects
+                .unwrap_or_default()
+                .into_iter()
+                .map(|(root, project)| {
+                    let project = AgentSandboxProjectSettings {
+                        enabled: project.enabled,
+                        rw_maps: project.rw_maps.unwrap_or_default(),
+                        allow_hosts: project.allow_hosts.unwrap_or_default(),
+                    };
+                    (root, project)
+                })
+                .collect(),
+        }
+    }
+}
+
+/// The `agent_memory` settings section.
+#[derive(Clone, Debug, PartialEq, Eq, RegisterSetting)]
+pub struct AgentMemorySettings {
+    /// Whether the agents use ai-memory. Default: `true`.
+    pub enabled: bool,
+    /// The ai-memory MCP endpoint, as written.
+    pub endpoint: String,
+}
+
+impl Settings for AgentMemorySettings {
+    fn from_settings(content: &SettingsContent) -> Self {
+        let memory = content.agent_memory.clone().unwrap_or_default();
+        Self {
+            enabled: memory.enabled.unwrap_or(true),
+            endpoint: memory
+                .endpoint
+                .unwrap_or_else(|| DEFAULT_MEMORY_ENDPOINT.to_string()),
+        }
+    }
+}
diff --git a/crates/agent_sandbox/src/settings_tests.rs b/crates/agent_sandbox/src/settings_tests.rs
new file mode 100644
index 0000000..d66dd8f
--- /dev/null
+++ b/crates/agent_sandbox/src/settings_tests.rs
@@ -0,0 +1,366 @@
+// Story 043, task 1.2 — user-only settings, defaults and the per-project policy (R2.1, R2.2,
+// R2.3, R9.4; A1, A5, A6, A8). Wired in the crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod settings_tests;
+//
+// Validation filter: `settings` (every test path is `settings_tests::…`).
+//
+// Contract (design.md §agent_sandbox::settings, §Data Models). Assumed signatures — adjust the
+// `use` and field access only, never the assertions:
+//   - `crate::AgentSandboxSettings: settings::Settings`, `get_global(cx)`, with
+//     `project_policy(&self, root: &Path) -> ProjectPolicy` and
+//     `effective_enabled(&self, root: &Path) -> bool`; public `ai_jail_path: Option<_>` and
+//     `claude_config_dir` (String or PathBuf).
+//   - `crate::ProjectPolicy { enabled: bool, rw_maps: Vec<String|PathBuf>, allow_hosts: Vec<String> }`
+//     holding the raw (unvalidated) merge of the defaults with `projects[root]`.
+//   - `crate::AgentMemorySettings: settings::Settings` with `enabled: bool` and `endpoint`
+//     (String or Url; compared through `to_string()`).
+//   - `crate::init(cx)` registers both settings and the R2.2 project-file watcher.
+//
+// Hostile halves first:
+//   - project key, wrongly COLLAPSE: an entry for `/w/app` must not switch on `/w/app2` (string
+//     prefix) nor `/w/app/sub` (a child path is another root).
+//   - merge, wrongly COLLAPSE: `api.anthropic.com` beside `anthropic.com` stays two entries;
+//     wrongly SPLIT: a host or map listed in both the defaults and the project appears once.
+//   - project file, both directions: a project `.zed/settings.json` can neither turn the jail on
+//     nor off, nor turn memory off; the warning fires for `agent_sandbox`/`agent_memory` only,
+//     not for look-alike keys (`agent_sandboxes`, `agent_memory_notes`, `agent_servers`).
+
+use std::ffi::OsStr;
+use std::path::Path;
+use std::sync::Arc;
+
+use fs::FakeFs;
+use gpui::{TestAppContext, UpdateGlobal as _};
+use project::Project;
+use serde_json::json;
+use settings::{Settings as _, SettingsStore};
+
+use crate::test_log_capture::LogCapture;
+use crate::{AgentMemorySettings, AgentSandboxSettings};
+
+fn init(cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        crate::init(cx);
+    });
+}
+
+fn set_user_settings(json: serde_json::Value, cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&json.to_string(), cx)
+                .result()
+                .expect("user settings parse");
+        })
+    });
+    cx.run_until_parked();
+}
+
+fn strs<T: AsRef<OsStr>>(items: &[T]) -> Vec<String> {
+    items
+        .iter()
+        .map(|item| item.as_ref().to_string_lossy().into_owned())
+        .collect()
+}
+
+fn enabled_for(root: &str, cx: &mut TestAppContext) -> bool {
+    cx.update(|cx| AgentSandboxSettings::get_global(cx).effective_enabled(Path::new(root)))
+}
+
+fn hosts_for(root: &str, cx: &mut TestAppContext) -> Vec<String> {
+    cx.update(|cx| {
+        strs(
+            &AgentSandboxSettings::get_global(cx)
+                .project_policy(Path::new(root))
+                .allow_hosts,
+        )
+    })
+}
+
+fn maps_for(root: &str, cx: &mut TestAppContext) -> Vec<String> {
+    cx.update(|cx| {
+        strs(
+            &AgentSandboxSettings::get_global(cx)
+                .project_policy(Path::new(root))
+                .rw_maps,
+        )
+    })
+}
+
+// ---------------------------------------------------------------------------------------------
+// R2.3 — keyed by the absolute root; a project without an entry follows the default
+// ---------------------------------------------------------------------------------------------
+
+#[gpui::test]
+fn settings_project_entry_does_not_leak_to_prefix_or_child_roots(cx: &mut TestAppContext) {
+    init(cx);
+    set_user_settings(
+        json!({ "agent_sandbox": { "enabled": false, "projects": { "/w/app": { "enabled": true } } } }),
+        cx,
+    );
+    assert!(
+        !enabled_for("/w/app2", cx),
+        "an entry for /w/app must not apply to /w/app2 (string prefix)"
+    );
+    assert!(
+        !enabled_for("/w/app/sub", cx),
+        "an entry for /w/app must not apply to the root /w/app/sub"
+    );
+    assert!(enabled_for("/w/app", cx), "the keyed project itself is on");
+}
+
+#[gpui::test]
+fn settings_project_entry_off_overrides_default_on(cx: &mut TestAppContext) {
+    init(cx);
+    set_user_settings(
+        json!({ "agent_sandbox": { "enabled": true, "projects": { "/w/app": { "enabled": false } } } }),
+        cx,
+    );
+    assert!(!enabled_for("/w/app", cx), "the project's own off wins");
+    assert!(
+        enabled_for("/w/other", cx),
+        "a project without an entry follows the default (on)"
+    );
+}
+
+#[gpui::test]
+fn settings_fresh_install_defaults(cx: &mut TestAppContext) {
+    init(cx);
+    assert!(
+        !enabled_for("/w/app", cx),
+        "the default switch is off on a fresh install (R2.3)"
+    );
+    assert_eq!(
+        hosts_for("/w/app", cx),
+        vec![
+            "anthropic.com".to_string(),
+            "platform.claude.com".to_string()
+        ],
+        "allowed-host defaults are the measured login minimum (A8), in this order"
+    );
+    assert!(
+        maps_for("/w/app", cx).is_empty(),
+        "no writable folder by default"
+    );
+    cx.update(|cx| {
+        let sandbox = AgentSandboxSettings::get_global(cx);
+        assert!(
+            sandbox.ai_jail_path.is_none(),
+            "null ai_jail_path → PATH lookup"
+        );
+        assert!(
+            sandbox.claude_config_dir.ends_with(".claude-jailed"),
+            "the Claude config dir defaults to ~/.claude-jailed (A5)"
+        );
+        let memory = AgentMemorySettings::get_global(cx);
+        assert!(memory.enabled, "memory is on by default (A6)");
+        assert_eq!(memory.endpoint.to_string(), "http://127.0.0.1:49374/mcp");
+    });
+}
+
+// ---------------------------------------------------------------------------------------------
+// R2.3/R1.3 — defaults ∪ project, without duplicates, in the configured order
+// ---------------------------------------------------------------------------------------------
+
+#[gpui::test]
+fn settings_merge_keeps_near_identical_entries_apart(cx: &mut TestAppContext) {
+    init(cx);
+    set_user_settings(
+        json!({ "agent_sandbox": {
+            "allow_hosts": ["anthropic.com"],
+            "rw_maps": ["/data/shared"],
+            "projects": { "/w/app": {
+                "allow_hosts": ["api.anthropic.com"],
+                "rw_maps": ["/data/shared-2"]
+            } }
+        } }),
+        cx,
+    );
+    assert_eq!(
+        hosts_for("/w/app", cx),
+        vec!["anthropic.com".to_string(), "api.anthropic.com".to_string()]
+    );
+    assert_eq!(
+        maps_for("/w/app", cx),
+        vec!["/data/shared".to_string(), "/data/shared-2".to_string()]
+    );
+}
+
+#[gpui::test]
+fn settings_merge_collapses_entries_listed_twice(cx: &mut TestAppContext) {
+    init(cx);
+    set_user_settings(
+        json!({ "agent_sandbox": {
+            "allow_hosts": ["anthropic.com", "platform.claude.com"],
+            "rw_maps": ["/data/shared"],
+            "projects": { "/w/app": {
+                "allow_hosts": ["registry.npmjs.org", "anthropic.com", "registry.npmjs.org"],
+                "rw_maps": ["/data/shared", "/data/cache"]
+            } }
+        } }),
+        cx,
+    );
+    assert_eq!(
+        hosts_for("/w/app", cx),
+        vec![
+            "anthropic.com".to_string(),
+            "platform.claude.com".to_string(),
+            "registry.npmjs.org".to_string()
+        ],
+        "defaults first, then the project's own, each once"
+    );
+    assert_eq!(
+        maps_for("/w/app", cx),
+        vec!["/data/shared".to_string(), "/data/cache".to_string()]
+    );
+}
+
+#[gpui::test]
+fn settings_merge_keeps_configured_order_not_sorted(cx: &mut TestAppContext) {
+    init(cx);
+    set_user_settings(
+        json!({ "agent_sandbox": {
+            "allow_hosts": [],
+            "projects": { "/w/app": { "allow_hosts": ["z.example.com", "a.example.com"] } }
+        } }),
+        cx,
+    );
+    assert_eq!(
+        hosts_for("/w/app", cx),
+        vec!["z.example.com".to_string(), "a.example.com".to_string()]
+    );
+}
+
+// ---------------------------------------------------------------------------------------------
+// R2.1/R2.2 — project files cannot feed the keys; one warning per file naming it
+// ---------------------------------------------------------------------------------------------
+
+async fn project_with(
+    fs: &Arc<FakeFs>,
+    roots: &[&str],
+    cx: &mut TestAppContext,
+) -> gpui::Entity<Project> {
+    let paths: Vec<&Path> = roots.iter().map(|root| Path::new(*root)).collect();
+    let project = Project::test(fs.clone(), paths, cx).await;
+    cx.run_until_parked();
+    project
+}
+
+#[gpui::test]
+async fn settings_project_file_cannot_turn_the_jail_on(cx: &mut TestAppContext) {
+    init(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/w/app",
+        json!({ ".zed": { "settings.json": r#"{ "agent_sandbox": { "enabled": true, "projects": { "/w/app": { "enabled": true } } } }"# } }),
+    )
+    .await;
+    let _project = project_with(&fs, &["/w/app"], cx).await;
+    assert!(
+        !enabled_for("/w/app", cx),
+        "a project settings file must not switch its own jail on (R2.1)"
+    );
+}
+
+#[gpui::test]
+async fn settings_project_file_cannot_turn_the_jail_or_memory_off(cx: &mut TestAppContext) {
+    init(cx);
+    set_user_settings(
+        json!({ "agent_sandbox": { "projects": { "/w/app": { "enabled": true } } } }),
+        cx,
+    );
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/w/app",
+        json!({ ".zed": { "settings.json": r#"{ "agent_sandbox": { "enabled": false, "projects": { "/w/app": { "enabled": false } } }, "agent_memory": { "enabled": false } }"# } }),
+    )
+    .await;
+    let _project = project_with(&fs, &["/w/app"], cx).await;
+    assert!(
+        enabled_for("/w/app", cx),
+        "a jailed agent that writes .zed/settings.json must not loosen its jail (A1)"
+    );
+    assert!(
+        cx.update(|cx| AgentMemorySettings::get_global(cx).enabled),
+        "agent_memory is read from the user file only"
+    );
+}
+
+#[gpui::test]
+async fn settings_lookalike_project_keys_do_not_warn(cx: &mut TestAppContext) {
+    init(cx);
+    let logs = LogCapture::start();
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/w/app",
+        json!({ ".zed": { "settings.json": r#"{ "agent_sandboxes": 1, "agent_memory_notes": "x", "agent_servers": {}, "languages": { "Rust": { "agent_sandbox": true } } }"# } }),
+    )
+    .await;
+    let _project = project_with(&fs, &["/w/app"], cx).await;
+    assert!(
+        logs.matching("event=project_key_ignored").is_empty(),
+        "only the exact top-level keys warn, got {:?}",
+        logs.matching("project_key_ignored")
+    );
+}
+
+#[gpui::test]
+async fn settings_project_key_warns_once_per_file_naming_it(cx: &mut TestAppContext) {
+    init(cx);
+    let logs = LogCapture::start();
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/w/app",
+        json!({ ".zed": { "settings.json": r#"{ "agent_sandbox": { "enabled": true, "claude_config_dir": "/secret/marker-value-1" } }"# } }),
+    )
+    .await;
+    fs.insert_tree(
+        "/w/lib",
+        json!({ ".zed": { "settings.json": r#"{ "agent_memory": { "enabled": false } }"# } }),
+    )
+    .await;
+    let _project = project_with(&fs, &["/w/app", "/w/lib"], cx).await;
+    cx.run_until_parked();
+
+    let warnings = logs.matching("event=project_key_ignored");
+    let app: Vec<_> = warnings
+        .iter()
+        .filter(|(_, line)| line.contains("/w/app/.zed/settings.json"))
+        .collect();
+    let lib: Vec<_> = warnings
+        .iter()
+        .filter(|(_, line)| line.contains("/w/lib/.zed/settings.json"))
+        .collect();
+    assert_eq!(
+        app.len(),
+        1,
+        "one warning for /w/app's file, got {warnings:?}"
+    );
+    assert_eq!(
+        lib.len(),
+        1,
+        "a second file gets its own warning, got {warnings:?}"
+    );
+    assert!(
+        warnings.iter().all(|(level, _)| *level == log::Level::Warn),
+        "logged at warn level"
+    );
+    assert!(
+        !logs.all_text().contains("marker-value-1"),
+        "the warning names the file, never a value from it (R9.4)"
+    );
+
+    // Unrelated activity does not repeat the warning for an unchanged file.
+    set_user_settings(json!({ "agent_sandbox": { "enabled": false } }), cx);
+    cx.run_until_parked();
+    assert_eq!(
+        logs.matching("/w/app/.zed/settings.json").len(),
+        1,
+        "still one warning for the unchanged file"
+    );
+}
diff --git a/crates/agent_sandbox/src/test_log_capture.rs b/crates/agent_sandbox/src/test_log_capture.rs
new file mode 100644
index 0000000..de75316
--- /dev/null
+++ b/crates/agent_sandbox/src/test_log_capture.rs
@@ -0,0 +1,97 @@
+// Story 043 — shared test helper (not a test module of its own). Captures `log` records on the
+// current thread so settings_tests (R2.2), memory_tests (R9.3) and launch_tests can assert the
+// structured lines. Wired in the crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod test_log_capture;
+//
+// One logger per test binary: every agent_sandbox test file MUST use this helper instead of
+// installing its own, or `log::set_logger` fails for the second one. Run these tests without
+// `RUST_LOG`/`ZED_LOG`/`CI` set: `zlog::init_test` installs its logger when one of them is set.
+// gpui tests run their foreground and background tasks on the test thread, so a thread-local
+// buffer sees every line the code under test emits.
+
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::sync::Once;
+
+thread_local! {
+    static CAPTURED: RefCell<Option<Vec<(log::Level, String)>>> = const { RefCell::new(None) };
+}
+
+pub(crate) struct LogCapture {
+    _private: (),
+}
+
+impl LogCapture {
+    pub(crate) fn start() -> Self {
+        static INSTALL: Once = Once::new();
+        INSTALL.call_once(|| {
+            log::set_logger(&CaptureLogger).expect(
+                "agent_sandbox test logger: another logger is installed \
+                 (unset RUST_LOG, ZED_LOG and CI for these tests)",
+            );
+            // The level is process-wide while the buffer is per thread: raise it once and never
+            // lower it, or a parallel test's Drop silences this one mid-test.
+            log::set_max_level(log::LevelFilter::Trace);
+        });
+        CAPTURED.with_borrow_mut(|lines| *lines = Some(Vec::new()));
+        Self { _private: () }
+    }
+
+    /// Every captured line, in order.
+    pub(crate) fn lines(&self) -> Vec<(log::Level, String)> {
+        CAPTURED.with_borrow(|lines| lines.clone().unwrap_or_default())
+    }
+
+    /// Captured lines containing `needle`.
+    pub(crate) fn matching(&self, needle: &str) -> Vec<(log::Level, String)> {
+        self.lines()
+            .into_iter()
+            .filter(|(_, line)| line.contains(needle))
+            .collect()
+    }
+
+    /// The whole capture as one string, for "never logged" assertions.
+    pub(crate) fn all_text(&self) -> String {
+        self.lines()
+            .into_iter()
+            .map(|(_, line)| line)
+            .collect::<Vec<_>>()
+            .join("\n")
+    }
+}
+
+impl Drop for LogCapture {
+    fn drop(&mut self) {
+        CAPTURED.with_borrow_mut(|lines| *lines = None);
+    }
+}
+
+/// Parses the `key=value` tokens of a structured line (Q14). Tokens without `=` are skipped.
+pub(crate) fn kv(line: &str) -> HashMap<String, String> {
+    line.split_whitespace()
+        .filter_map(|token| {
+            let (key, value) = token.split_once('=')?;
+            Some((key.to_string(), value.to_string()))
+        })
+        .collect()
+}
+
+struct CaptureLogger;
+
+impl log::Log for CaptureLogger {
+    fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
+        metadata.level() <= log::max_level()
+    }
+
+    fn log(&self, record: &log::Record<'_>) {
+        CAPTURED.with_borrow_mut(|lines| {
+            if let Some(lines) = lines {
+                lines.push((record.level(), record.args().to_string()));
+            }
+        });
+    }
+
+    fn flush(&self) {}
+}
diff --git a/crates/agent_sandbox/src/validate.rs b/crates/agent_sandbox/src/validate.rs
new file mode 100644
index 0000000..26b59d1
--- /dev/null
+++ b/crates/agent_sandbox/src/validate.rs
@@ -0,0 +1,678 @@
+//! Validation of the sandbox settings' entries: writable folders (R2.4), allowed hosts (R2.5)
+//! and their counts (R2.6). Every error's `Display` names the entry and the rule it breaks, so
+//! the settings page (R3.4) and a launch refusal show the same text.
+
+use std::collections::HashSet;
+use std::fmt;
+use std::fs;
+use std::io;
+use std::net::IpAddr;
+use std::path::{Component, Path, PathBuf};
+use std::str::FromStr;
+use std::sync::LazyLock;
+
+use regex::Regex;
+
+/// Most writable folders a project may end up with (R2.6).
+pub const MAX_RW_MAPS: usize = 32;
+/// Most allowed hosts a project may end up with (R2.6).
+pub const MAX_ALLOWED_HOSTS: usize = 64;
+const MAX_HOST_LENGTH: usize = 253;
+/// Linux's own limit on symlinks followed while resolving one path (`MAXSYMLINKS`).
+const MAX_SYMLINK_HOPS: usize = 40;
+
+const HOST_PATTERN: &str =
+    r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$";
+
+static HOST_REGEX: LazyLock<Option<Regex>> = LazyLock::new(|| match Regex::new(HOST_PATTERN) {
+    Ok(regex) => Some(regex),
+    Err(error) => {
+        // A pattern that does not compile is a build defect; every host is refused until fixed.
+        log::error!("agent_sandbox event=host_pattern_invalid error={error}");
+        None
+    }
+});
+
+/// Bounds on the scan of the protected folders for symlinks. The real `~/.claude` measured 75k
+/// entries, 17 levels deep (2026-10-12).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct WalkLimits {
+    pub max_entries: usize,
+    pub max_depth: usize,
+}
+
+impl WalkLimits {
+    pub const DEFAULT: Self = Self {
+        max_entries: 250_000,
+        max_depth: 32,
+    };
+}
+
+/// Why the scan of a protected folder stopped before it was complete.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum WalkStop {
+    TooManyEntries { limit: usize },
+    TooDeep { limit: usize },
+    Unreadable { kind: io::ErrorKind },
+}
+
+impl fmt::Display for WalkStop {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::TooManyEntries { limit } => {
+                write!(formatter, "it holds more than {limit} entries")
+            }
+            Self::TooDeep { limit } => write!(formatter, "it is nested deeper than {limit} levels"),
+            Self::Unreadable { kind } => write!(formatter, "it could not be read: {kind}"),
+        }
+    }
+}
+
+/// The rule that keeps a path out of the jail's writable set, or a project root out of the jail
+/// (R2.8). A path is refused when it is, lies inside, or contains a protected path.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ForbiddenRule {
+    Root,
+    Home,
+    AboveHome { home: PathBuf },
+    Protected { protected: PathBuf },
+    LinkTarget { target: PathBuf, link: PathBuf },
+    RuntimeDir { dir: PathBuf },
+    ProtectedSetIncomplete { dir: PathBuf, stop: WalkStop },
+}
+
+impl fmt::Display for ForbiddenRule {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Root => write!(formatter, "is the root directory /"),
+            Self::Home => write!(formatter, "is the home directory"),
+            Self::AboveHome { home } => {
+                write!(formatter, "contains the home directory {}", home.display())
+            }
+            Self::Protected { protected } => write!(
+                formatter,
+                "is, lies inside, or contains the protected path {}",
+                protected.display()
+            ),
+            Self::LinkTarget { target, link } => write!(
+                formatter,
+                "is, lies inside, or contains {}, the target of the symlink {} in a protected folder",
+                target.display(),
+                link.display()
+            ),
+            Self::RuntimeDir { dir } => write!(
+                formatter,
+                "is, lies inside, or contains the runtime directory {}, which holds Zeo's devtools socket",
+                dir.display()
+            ),
+            Self::ProtectedSetIncomplete { dir, stop } => write!(
+                formatter,
+                "cannot be checked: the protected folder {} was not fully scanned for symlinks ({stop}), so every folder is refused",
+                dir.display()
+            ),
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum ProtectedKind {
+    Listed,
+    LinkTarget { link: PathBuf },
+    RuntimeDir,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct ProtectedPath {
+    path: PathBuf,
+    kind: ProtectedKind,
+}
+
+/// The paths no writable folder and no project root may be, lie inside, or contain: Zeo's config
+/// directory, `~/.claude`, `~/.claude.json`, `~/.ssh`, `~/.gnupg`, the target of every symlink
+/// found inside them, and `$XDG_RUNTIME_DIR` (R2.11). `/` and the home directory are refused by
+/// equality, and the home directory's ancestors by their own rule. Build it once per launch: it
+/// scans the protected folders.
+#[derive(Clone, Debug)]
+pub struct ProtectedSet {
+    home: PathBuf,
+    canonical_home: PathBuf,
+    entries: Vec<ProtectedPath>,
+    incomplete: Option<(PathBuf, WalkStop)>,
+}
+
+impl ProtectedSet {
+    pub fn new(home: &Path, config_dir: &Path, runtime_dir: Option<&Path>) -> Self {
+        Self::with_limits(home, config_dir, runtime_dir, WalkLimits::DEFAULT)
+    }
+
+    /// Reads the runtime directory from `$XDG_RUNTIME_DIR`, skipped when unset or not absolute.
+    pub fn from_env(home: &Path, config_dir: &Path) -> Self {
+        let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
+            .map(PathBuf::from)
+            .filter(|dir| dir.is_absolute());
+        Self::new(home, config_dir, runtime_dir.as_deref())
+    }
+
+    pub fn with_limits(
+        home: &Path,
+        config_dir: &Path,
+        runtime_dir: Option<&Path>,
+        limits: WalkLimits,
+    ) -> Self {
+        let mut set = Self {
+            home: home.to_path_buf(),
+            canonical_home: resolve_lenient(home),
+            entries: Vec::new(),
+            incomplete: None,
+        };
+        let listed = [
+            config_dir.to_path_buf(),
+            home.join(".claude"),
+            home.join(".claude.json"),
+            home.join(".ssh"),
+            home.join(".gnupg"),
+        ];
+        let mut walk_roots = Vec::new();
+        for path in &listed {
+            let resolved = resolve_lenient(path);
+            // A symlinked protected path is protected where it sits too: whoever can replace the
+            // link can point Zeo at a folder of their choosing.
+            set.insert(lexical_anchor(path), ProtectedKind::Listed);
+            set.insert(resolved.clone(), ProtectedKind::Listed);
+            walk_roots.push(resolved);
+        }
+        if let Some(runtime_dir) = runtime_dir {
+            // Sockets and FUSE mounts live here: protected in full, never walked.
+            set.insert(lexical_anchor(runtime_dir), ProtectedKind::RuntimeDir);
+            set.insert(resolve_lenient(runtime_dir), ProtectedKind::RuntimeDir);
+        }
+        set.walk(walk_roots, limits);
+        set
+    }
+
+    /// Every protected path, canonical where it exists.
+    pub fn paths(&self) -> impl Iterator<Item = &Path> {
+        self.entries.iter().map(|entry| entry.path.as_path())
+    }
+
+    /// The rule a canonical path breaks, if any. Also answers for a project root (R2.8).
+    pub fn rule_for(&self, canonical: &Path) -> Option<ForbiddenRule> {
+        if canonical == Path::new("/") {
+            return Some(ForbiddenRule::Root);
+        }
+        if canonical == self.canonical_home {
+            return Some(ForbiddenRule::Home);
+        }
+        if self.canonical_home.starts_with(canonical) {
+            return Some(ForbiddenRule::AboveHome {
+                home: self.canonical_home.clone(),
+            });
+        }
+        for entry in &self.entries {
+            if canonical.starts_with(&entry.path) || entry.path.starts_with(canonical) {
+                return Some(match &entry.kind {
+                    ProtectedKind::Listed => ForbiddenRule::Protected {
+                        protected: entry.path.clone(),
+                    },
+                    ProtectedKind::LinkTarget { link } => ForbiddenRule::LinkTarget {
+                        target: entry.path.clone(),
+                        link: link.clone(),
+                    },
+                    ProtectedKind::RuntimeDir => ForbiddenRule::RuntimeDir {
+                        dir: entry.path.clone(),
+                    },
+                });
+            }
+        }
+        self.incomplete
+            .as_ref()
+            .map(|(dir, stop)| ForbiddenRule::ProtectedSetIncomplete {
+                dir: dir.clone(),
+                stop: *stop,
+            })
+    }
+
+    fn insert(&mut self, path: PathBuf, kind: ProtectedKind) {
+        if !self.entries.iter().any(|entry| entry.path == path) {
+            self.entries.push(ProtectedPath { path, kind });
+        }
+    }
+
+    fn covers(&self, path: &Path) -> bool {
+        self.entries
+            .iter()
+            .any(|entry| path.starts_with(&entry.path))
+    }
+
+    /// Scans the protected folders without following symlinked directories. Every symlink found
+    /// protects its resolved target, and a directory target is scanned in turn unless it already
+    /// lies inside a protected path, which is what ends a cycle. A cap or an unreadable folder
+    /// leaves the set incomplete, and an incomplete set refuses every path.
+    fn walk(&mut self, roots: Vec<PathBuf>, limits: WalkLimits) {
+        let mut queue: Vec<(PathBuf, usize)> = roots
+            .into_iter()
+            .filter(|root| root.is_dir())
+            .map(|root| (root, 0))
+            .collect();
+        let mut walked = HashSet::new();
+        let mut entries = 0usize;
+        while let Some((dir, depth)) = queue.pop() {
+            if !walked.insert(dir.clone()) {
+                continue;
+            }
+            let read_dir = match fs::read_dir(&dir) {
+                Ok(read_dir) => read_dir,
+                Err(error) => return self.stop(dir, WalkStop::Unreadable { kind: error.kind() }),
+            };
+            for entry in read_dir {
+                let entry = match entry {
+                    Ok(entry) => entry,
+                    Err(error) => {
+                        return self.stop(dir, WalkStop::Unreadable { kind: error.kind() });
+                    }
+                };
+                entries += 1;
+                if entries > limits.max_entries {
+                    return self.stop(
+                        dir,
+                        WalkStop::TooManyEntries {
+                            limit: limits.max_entries,
+                        },
+                    );
+                }
+                let path = entry.path();
+                let file_type = match entry.file_type() {
+                    Ok(file_type) => file_type,
+                    Err(error) => {
+                        return self.stop(path, WalkStop::Unreadable { kind: error.kind() });
+                    }
+                };
+                if file_type.is_dir() {
+                    if depth + 1 > limits.max_depth {
+                        return self.stop(
+                            path,
+                            WalkStop::TooDeep {
+                                limit: limits.max_depth,
+                            },
+                        );
+                    }
+                    queue.push((path, depth + 1));
+                } else if file_type.is_symlink() {
+                    let target = resolve_lenient(&path);
+                    // A target inside a protected path is already covered, for equal, inside and
+                    // above alike, and is or will be scanned from there.
+                    if self.covers(&target) {
+                        continue;
+                    }
+                    self.insert(target.clone(), ProtectedKind::LinkTarget { link: path });
+                    if target.is_dir() {
+                        queue.push((target, 0));
+                    }
+                }
+            }
+        }
+    }
+
+    fn stop(&mut self, dir: PathBuf, stop: WalkStop) {
+        self.incomplete = Some((dir, stop));
+    }
+}
+
+/// Resolves a path that may not exist yet: dangling link chains are followed (up to Linux's 40
+/// hops), the deepest existing ancestor is canonicalized and the missing tail re-appended, so a
+/// protected path protects the place it will be created in.
+fn resolve_lenient(path: &Path) -> PathBuf {
+    let mut hops = MAX_SYMLINK_HOPS;
+    resolve_with_budget(path, &mut hops)
+}
+
+fn resolve_with_budget(path: &Path, hops: &mut usize) -> PathBuf {
+    if let Ok(canonical) = path.canonicalize() {
+        return canonical;
+    }
+    if *hops > 0
+        && fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
+        && let Ok(target) = fs::read_link(path)
+    {
+        *hops -= 1;
+        let target = match path.parent() {
+            Some(parent) if target.is_relative() => parent.join(target),
+            _ => target,
+        };
+        return resolve_with_budget(&target, hops);
+    }
+    match (path.parent(), path.file_name()) {
+        (Some(parent), Some(name)) => resolve_with_budget(parent, hops).join(name),
+        _ => normalize_lexically(path),
+    }
+}
+
+/// The path itself, with its parent canonicalized: where a symlinked protected path sits.
+fn lexical_anchor(path: &Path) -> PathBuf {
+    match (path.parent(), path.file_name()) {
+        (Some(parent), Some(name)) => resolve_lenient(parent).join(name),
+        _ => resolve_lenient(path),
+    }
+}
+
+fn normalize_lexically(path: &Path) -> PathBuf {
+    let mut normalized = PathBuf::new();
+    for component in path.components() {
+        match component {
+            Component::CurDir => {}
+            Component::ParentDir => {
+                normalized.pop();
+            }
+            other => normalized.push(other),
+        }
+    }
+    normalized
+}
+
+/// Expands a leading `~` or `~/` against `home`; anything else is returned as written.
+pub fn expand_tilde(raw: &str, home: &Path) -> PathBuf {
+    if raw == "~" {
+        home.to_path_buf()
+    } else if let Some(rest) = raw.strip_prefix("~/") {
+        home.join(rest)
+    } else {
+        PathBuf::from(raw)
+    }
+}
+
+/// A writable folder that may not be mapped (R2.4).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RwMapError {
+    NotAbsolute {
+        entry: String,
+    },
+    Colon {
+        entry: String,
+    },
+    Missing {
+        entry: String,
+        kind: io::ErrorKind,
+    },
+    NotUtf8 {
+        entry: String,
+        canonical: PathBuf,
+    },
+    ColonInTarget {
+        entry: String,
+        canonical: String,
+    },
+    Forbidden {
+        entry: String,
+        canonical: PathBuf,
+        rule: ForbiddenRule,
+    },
+    NotADirectory {
+        entry: String,
+        canonical: PathBuf,
+    },
+}
+
+impl fmt::Display for RwMapError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::NotAbsolute { entry } if entry.is_empty() => write!(
+                formatter,
+                "A writable folder entry is empty; it must be an absolute path."
+            ),
+            Self::NotAbsolute { entry } => write!(
+                formatter,
+                "Writable folder \"{entry}\" is not an absolute path; start it with / or ~/."
+            ),
+            Self::Colon { entry } => write!(
+                formatter,
+                "Writable folder \"{entry}\" contains ':', which ai-jail reads as SOURCE:DEST."
+            ),
+            Self::Missing { entry, kind } => write!(
+                formatter,
+                "Writable folder \"{entry}\" does not exist or cannot be resolved ({kind})."
+            ),
+            Self::NotUtf8 { entry, canonical } => write!(
+                formatter,
+                "Writable folder \"{entry}\" resolves to {}, which is not valid UTF-8 and cannot be passed to ai-jail.",
+                canonical.display()
+            ),
+            Self::ColonInTarget { entry, canonical } => write!(
+                formatter,
+                "Writable folder \"{entry}\" resolves to {canonical}, which contains ':', which ai-jail reads as SOURCE:DEST."
+            ),
+            Self::Forbidden {
+                entry,
+                canonical,
+                rule,
+            } => write!(
+                formatter,
+                "Writable folder \"{entry}\" ({}) {rule}.",
+                canonical.display()
+            ),
+            Self::NotADirectory { entry, canonical } => write!(
+                formatter,
+                "Writable folder \"{entry}\" ({}) is not a folder.",
+                canonical.display()
+            ),
+        }
+    }
+}
+
+impl std::error::Error for RwMapError {}
+
+/// Validates one writable folder against the protected set built for `home` and `config_dir`
+/// (plus `$XDG_RUNTIME_DIR`), returning its canonical path. Build a [`ProtectedSet`] once and
+/// call [`validate_rw_map_with`] when validating several entries.
+pub fn validate_rw_map(raw: &str, home: &Path, config_dir: &Path) -> Result<PathBuf, RwMapError> {
+    validate_rw_map_with(raw, &ProtectedSet::from_env(home, config_dir))
+}
+
+pub fn validate_rw_map_with(raw: &str, set: &ProtectedSet) -> Result<PathBuf, RwMapError> {
+    let entry = || raw.to_string();
+    let expanded = expand_tilde(raw, &set.home);
+    if raw.is_empty() || !expanded.is_absolute() {
+        return Err(RwMapError::NotAbsolute { entry: entry() });
+    }
+    if raw.contains(':') || expanded.as_os_str().as_encoded_bytes().contains(&b':') {
+        return Err(RwMapError::Colon { entry: entry() });
+    }
+    let canonical = expanded
+        .canonicalize()
+        .map_err(|error| RwMapError::Missing {
+            entry: entry(),
+            kind: error.kind(),
+        })?;
+    let Some(text) = canonical.to_str() else {
+        return Err(RwMapError::NotUtf8 {
+            entry: entry(),
+            canonical,
+        });
+    };
+    // A symlink can resolve to a ':' target, and the canonical path is what reaches --rw-map.
+    if text.contains(':') {
+        return Err(RwMapError::ColonInTarget {
+            entry: entry(),
+            canonical: text.to_string(),
+        });
+    }
+    if let Some(rule) = set.rule_for(&canonical) {
+        return Err(RwMapError::Forbidden {
+            entry: entry(),
+            canonical,
+            rule,
+        });
+    }
+    if !canonical.is_dir() {
+        return Err(RwMapError::NotADirectory {
+            entry: entry(),
+            canonical,
+        });
+    }
+    Ok(canonical)
+}
+
+/// What makes an allowed host something other than a DNS name.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum HostShape {
+    Scheme,
+    Path,
+    Port,
+    Wildcard,
+    UpperCase,
+    Malformed,
+}
+
+/// An allowed host that is not a DNS name (R2.5).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum HostError {
+    Empty,
+    TooLong { entry: String, length: usize },
+    IpLiteral { entry: String },
+    Localhost { entry: String },
+    NotDnsName { entry: String, shape: HostShape },
+    NumericLastLabel { entry: String },
+    PatternUnavailable { entry: String },
+}
+
+impl fmt::Display for HostError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Empty => write!(formatter, "An allowed host entry is empty."),
+            Self::TooLong { entry, length } => write!(
+                formatter,
+                "Allowed host \"{entry}\" is {length} characters long; a DNS name has at most {MAX_HOST_LENGTH}."
+            ),
+            Self::IpLiteral { entry } => write!(
+                formatter,
+                "Allowed host \"{entry}\" is an IP address; only DNS names are allowed."
+            ),
+            Self::Localhost { entry } => write!(
+                formatter,
+                "Allowed host \"{entry}\" names localhost; loopback services are never allowed hosts."
+            ),
+            Self::NotDnsName { entry, shape } => {
+                let rule = match shape {
+                    HostShape::Scheme => "carries a scheme; write the bare host name",
+                    HostShape::Path => "carries a path; write the bare host name",
+                    HostShape::Port => "carries a port; write the bare host name",
+                    HostShape::Wildcard => "is a wildcard; list each host name",
+                    HostShape::UpperCase => "has upper-case letters; write it in lower case",
+                    HostShape::Malformed => {
+                        "is not a DNS name (two or more labels of a-z, 0-9 and inner '-', no trailing dot)"
+                    }
+                };
+                write!(formatter, "Allowed host \"{entry}\" {rule}.")
+            }
+            Self::NumericLastLabel { entry } => write!(
+                formatter,
+                "Allowed host \"{entry}\" ends in a numeric label, which resolves as an IP address; only DNS names are allowed."
+            ),
+            Self::PatternUnavailable { entry } => write!(
+                formatter,
+                "Allowed host \"{entry}\" cannot be checked: the host-name pattern is unavailable."
+            ),
+        }
+    }
+}
+
+impl std::error::Error for HostError {}
+
+pub fn validate_host(raw: &str) -> Result<String, HostError> {
+    let entry = || raw.to_string();
+    if raw.is_empty() {
+        return Err(HostError::Empty);
+    }
+    if raw.len() > MAX_HOST_LENGTH {
+        return Err(HostError::TooLong {
+            entry: entry(),
+            length: raw.len(),
+        });
+    }
+    let unbracketed = raw
+        .strip_prefix('[')
+        .and_then(|rest| rest.strip_suffix(']'))
+        .unwrap_or(raw);
+    if IpAddr::from_str(raw).is_ok() || IpAddr::from_str(unbracketed).is_ok() {
+        return Err(HostError::IpLiteral { entry: entry() });
+    }
+    let lower = raw.to_ascii_lowercase();
+    let name = lower.strip_suffix('.').unwrap_or(&lower);
+    if name == "localhost" || name.ends_with(".localhost") || name == "localhost.localdomain" {
+        return Err(HostError::Localhost { entry: entry() });
+    }
+    let shape = if raw.contains("://") {
+        Some(HostShape::Scheme)
+    } else if raw.contains('/') {
+        Some(HostShape::Path)
+    } else if raw.contains(':') {
+        Some(HostShape::Port)
+    } else if raw.contains('*') {
+        Some(HostShape::Wildcard)
+    } else if raw.chars().any(|character| character.is_uppercase()) {
+        Some(HostShape::UpperCase)
+    } else {
+        None
+    };
+    if let Some(shape) = shape {
+        return Err(HostError::NotDnsName {
+            entry: entry(),
+            shape,
+        });
+    }
+    let Some(regex) = HOST_REGEX.as_ref() else {
+        return Err(HostError::PatternUnavailable { entry: entry() });
+    };
+    if !regex.is_match(raw) {
+        return Err(HostError::NotDnsName {
+            entry: entry(),
+            shape: HostShape::Malformed,
+        });
+    }
+    // `127.1` and `0x7f.1` are not `IpAddr`s, yet the system resolver reads them as 127.0.0.1;
+    // an all-numeric final label is never a DNS name (RFC 3696 §2).
+    let last_label = raw.rsplit('.').next().unwrap_or(raw);
+    let numeric = last_label.bytes().all(|byte| byte.is_ascii_digit());
+    let hexadecimal = last_label.strip_prefix("0x").is_some_and(|digits| {
+        !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_hexdigit())
+    });
+    if numeric || hexadecimal {
+        return Err(HostError::NumericLastLabel { entry: entry() });
+    }
+    Ok(raw.to_string())
+}
+
+/// A project that would cross a count limit (R2.6), naming the entry that crosses it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum LimitError {
+    TooManyRwMaps { count: usize },
+    TooManyHosts { count: usize },
+}
+
+impl fmt::Display for LimitError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::TooManyRwMaps { count } => write!(
+                formatter,
+                "Writable folder #{} crosses the limit of {MAX_RW_MAPS} writable folders per project ({count} configured).",
+                MAX_RW_MAPS + 1
+            ),
+            Self::TooManyHosts { count } => write!(
+                formatter,
+                "Allowed host #{} crosses the limit of {MAX_ALLOWED_HOSTS} allowed hosts per project ({count} configured).",
+                MAX_ALLOWED_HOSTS + 1
+            ),
+        }
+    }
+}
+
+impl std::error::Error for LimitError {}
+
+pub fn check_limits(maps: usize, hosts: usize) -> Result<(), LimitError> {
+    if maps > MAX_RW_MAPS {
+        return Err(LimitError::TooManyRwMaps { count: maps });
+    }
+    if hosts > MAX_ALLOWED_HOSTS {
+        return Err(LimitError::TooManyHosts { count: hosts });
+    }
+    Ok(())
+}
diff --git a/crates/agent_sandbox/src/validate_tests.rs b/crates/agent_sandbox/src/validate_tests.rs
new file mode 100644
index 0000000..13b2a33
--- /dev/null
+++ b/crates/agent_sandbox/src/validate_tests.rs
@@ -0,0 +1,635 @@
+// Story 043, task 2.1 — entry validation (R2.4, R2.5, R2.6, R3.4 message text, Q4). Wired in the
+// crate root `src/agent_sandbox.rs` as
+//
+//     #[cfg(test)]
+//     mod validate_tests;
+//
+// Validation filter: `validate` (every test path is `validate_tests::…`).
+//
+// Contract (design.md §agent_sandbox::validate). Assumed signatures — adjust the `use` only:
+//   - `crate::validate::validate_rw_map(raw: &str, home: &Path, config_dir: &Path) -> Result<PathBuf, RwMapError>`
+//     returns the canonical path; a missing path is `RwMapError::Missing` (any payload).
+//   - `crate::validate::validate_host(raw: &str) -> Result<String, HostError>`
+//   - `crate::validate::check_limits(maps: usize, hosts: usize) -> Result<(), LimitError>`
+//   - every error implements `Display` naming the entry and the rule.
+//
+// Rule-wording assertions are deliberately loose (a keyword from the story's rule), so the
+// implementer chooses the sentence; the entry itself must always appear.
+//
+// Hostile halves first:
+//   - forbidden set, wrongly ACCEPT: a symlink whose target is inside `~/.ssh`; an ancestor of
+//     the config dir (`~/.config`); `~/.claude.json` itself.
+//   - forbidden set, wrongly REFUSE (string-prefix collapse): `~/.claude-jailed` (the default
+//     Claude config dir!), `~/.sshkeys`, `~/.config/zeo-other`, `~/.claude.json.d`.
+//   - canonical identity, wrongly SPLIT: `~/cache`, `<home>/cache/`, `<home>/cache/../cache` and
+//     a symlink to it all validate to the same PathBuf (the basis for R1.3 dedup).
+//   - hosts, wrongly ACCEPT: IP literals in every spelling, including `127.1` and `0x7f.1`, which
+//     the system resolver reads as 127.0.0.1 although `std::net::IpAddr` refuses them (an
+//     all-numeric final label is never a DNS name, RFC 3696 §2).
+//   - hosts, wrongly REFUSE: `1.2.3.4.example.com`, `localhost.example.com`, punycode,
+//     a name of exactly 253 characters.
+
+use std::os::unix::fs::symlink;
+use std::path::PathBuf;
+
+use crate::validate::{RwMapError, check_limits, validate_host, validate_rw_map};
+
+struct Home {
+    _tmp: tempfile::TempDir,
+    root: PathBuf,
+    home: PathBuf,
+    config: PathBuf,
+}
+
+fn home() -> Home {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let root = tmp.path().canonicalize().expect("canonical tempdir");
+    let home = root.join("home");
+    let config = home.join(".config/zeo");
+    for dir in [
+        ".config/zeo/themes",
+        ".config/zeo-other",
+        ".claude/projects",
+        ".claude-jailed",
+        ".claude.json.d",
+        ".ssh/sub",
+        ".sshkeys",
+        ".gnupg",
+        "projects/app",
+        "cache",
+        "a:b",
+    ] {
+        std::fs::create_dir_all(home.join(dir)).expect("fixture dir");
+    }
+    std::fs::write(home.join(".claude.json"), "{}").expect("fixture file");
+    std::fs::create_dir_all(root.join("outside/data")).expect("outside dir");
+    symlink(home.join(".ssh"), home.join("link-to-ssh")).expect("symlink");
+    symlink(home.join("cache"), home.join("link-to-cache")).expect("symlink");
+    Home {
+        _tmp: tmp,
+        root,
+        home,
+        config,
+    }
+}
+
+fn refused(h: &Home, raw: &str) -> String {
+    match validate_rw_map(raw, &h.home, &h.config) {
+        Ok(path) => panic!(
+            "{raw:?} must be refused, was accepted as {}",
+            path.display()
+        ),
+        Err(error) => {
+            let text = error.to_string();
+            assert!(
+                text.contains(raw),
+                "the error for {raw:?} must name the entry, got {text:?}"
+            );
+            text
+        }
+    }
+}
+
+fn accepted(h: &Home, raw: &str) -> PathBuf {
+    validate_rw_map(raw, &h.home, &h.config)
+        .unwrap_or_else(|error| panic!("{raw:?} must be accepted, got {error}"))
+}
+
+// --- R2.4: hostile, wrongly ACCEPT -----------------------------------------------------------
+
+#[test]
+fn validate_rw_map_refuses_inside_the_protected_set() {
+    let h = home();
+    for raw in [
+        "~/.ssh",
+        "~/.ssh/sub",
+        "~/.claude",
+        "~/.claude/projects",
+        "~/.gnupg",
+        "~/.config/zeo",
+        "~/.config/zeo/themes",
+        "~/.claude.json",
+    ] {
+        refused(&h, raw);
+    }
+    let absolute = format!("{}/.ssh/sub", h.home.display());
+    refused(&h, &absolute);
+}
+
+#[test]
+fn validate_rw_map_refuses_ancestors_of_the_protected_set() {
+    let h = home();
+    refused(&h, "~/.config");
+    let text = refused(&h, "~");
+    assert!(
+        text.to_lowercase().contains("home"),
+        "names the home rule: {text}"
+    );
+    refused(&h, &h.home.display().to_string());
+    refused(&h, "/");
+}
+
+#[test]
+fn validate_rw_map_refuses_a_symlink_into_the_protected_set() {
+    let h = home();
+    refused(&h, "~/link-to-ssh");
+}
+
+#[test]
+fn validate_rw_map_refuses_colon_before_existence() {
+    let h = home();
+    let text = refused(&h, "~/a:b");
+    assert!(text.contains(':'), "names the ':' rule: {text}");
+    match validate_rw_map("/does/not/exist:x", &h.home, &h.config) {
+        Ok(_) => panic!("a ':' path must be refused"),
+        Err(RwMapError::Missing { .. }) => {
+            panic!("':' is checked before existence; a missing ':' path is the ':' rule")
+        }
+        Err(_) => {}
+    }
+}
+
+#[test]
+fn validate_rw_map_refuses_relative_paths() {
+    let h = home();
+    for raw in ["cache", "./cache", "projects/app"] {
+        let text = refused(&h, raw);
+        assert!(
+            text.to_lowercase().contains("absolute"),
+            "names the absolute-path rule: {text}"
+        );
+    }
+    assert!(
+        validate_rw_map("", &h.home, &h.config).is_err(),
+        "empty is refused"
+    );
+}
+
+#[test]
+fn validate_rw_map_reports_missing_paths_as_missing() {
+    let h = home();
+    let raw = format!("{}/nope", h.home.display());
+    match validate_rw_map(&raw, &h.home, &h.config) {
+        Err(error @ RwMapError::Missing { .. }) => {
+            assert!(error.to_string().contains(&raw), "names the entry: {error}")
+        }
+        Err(other) => panic!("expected Missing, got {other}"),
+        Ok(path) => panic!("a missing path was accepted as {}", path.display()),
+    }
+}
+
+// --- R2.4: hostile, wrongly REFUSE -----------------------------------------------------------
+
+#[test]
+fn validate_rw_map_accepts_string_prefix_siblings_of_protected_paths() {
+    let h = home();
+    assert_eq!(
+        accepted(&h, "~/.claude-jailed"),
+        h.home.join(".claude-jailed")
+    );
+    assert_eq!(accepted(&h, "~/.sshkeys"), h.home.join(".sshkeys"));
+    assert_eq!(
+        accepted(&h, "~/.config/zeo-other"),
+        h.home.join(".config/zeo-other")
+    );
+    assert_eq!(
+        accepted(&h, "~/.claude.json.d"),
+        h.home.join(".claude.json.d")
+    );
+}
+
+#[test]
+fn validate_rw_map_canonicalizes_every_spelling_to_one_path() {
+    let h = home();
+    let expected = h.home.join("cache");
+    for raw in [
+        "~/cache".to_string(),
+        format!("{}/cache/", h.home.display()),
+        format!("{}/cache/../cache", h.home.display()),
+        "~/link-to-cache".to_string(),
+    ] {
+        assert_eq!(accepted(&h, &raw), expected, "{raw:?}");
+    }
+}
+
+#[test]
+fn validate_rw_map_accepts_folders_outside_home() {
+    let h = home();
+    let raw = h.root.join("outside/data").display().to_string();
+    assert_eq!(accepted(&h, &raw), h.root.join("outside/data"));
+    assert_eq!(accepted(&h, "~/projects/app"), h.home.join("projects/app"));
+}
+
+// --- R2.5: hosts ------------------------------------------------------------------------------
+
+#[test]
+fn validate_host_refuses_non_dns_names() {
+    let long_label = "a".repeat(64);
+    let too_long = format!(
+        "{}.{}.{}.{}",
+        "a".repeat(63),
+        "b".repeat(63),
+        "c".repeat(63),
+        "d".repeat(62)
+    );
+    assert_eq!(too_long.len(), 254);
+    let label_64 = format!("{long_label}.example.com");
+    for raw in [
+        "https://anthropic.com",
+        "anthropic.com:443",
+        "anthropic.com/path",
+        "*.anthropic.com",
+        "Anthropic.com",
+        "127.0.0.1",
+        "10.0.0.1",
+        "::1",
+        "[::1]",
+        "127.1",
+        "0x7f.1",
+        "localhost",
+        "-bad.example.com",
+        "bad-.example.com",
+        "single",
+        "a..b",
+        "anthropic.com.",
+        "",
+        label_64.as_str(),
+        too_long.as_str(),
+    ] {
+        match validate_host(raw) {
+            Ok(host) => panic!("{raw:?} must be refused, accepted as {host:?}"),
+            Err(error) => {
+                if !raw.is_empty() && raw.len() < 100 {
+                    assert!(
+                        error.to_string().contains(raw),
+                        "the error names the entry: {error}"
+                    );
+                }
+            }
+        }
+    }
+}
+
+#[test]
+fn validate_host_accepts_dns_names_that_look_like_refused_ones() {
+    let exactly_253 = format!(
+        "{}.{}.{}.{}",
+        "a".repeat(63),
+        "b".repeat(63),
+        "c".repeat(63),
+        "d".repeat(61)
+    );
+    assert_eq!(exactly_253.len(), 253);
+    for raw in [
+        "anthropic.com",
+        "platform.claude.com",
+        "registry.npmjs.org",
+        "api.anthropic.com",
+        "a-b.example.co",
+        "1.2.3.4.example.com",
+        "localhost.example.com",
+        "xn--bcher-kva.example",
+        "8.example.com",
+        exactly_253.as_str(),
+    ] {
+        assert_eq!(
+            validate_host(raw).unwrap_or_else(|error| panic!("{raw:?} must be accepted: {error}")),
+            raw
+        );
+    }
+}
+
+// --- R2.6: limits -----------------------------------------------------------------------------
+
+#[test]
+fn validate_limits_refuse_the_entry_that_crosses() {
+    let maps = check_limits(33, 0).expect_err("33 writable folders cross the limit");
+    assert!(maps.to_string().contains("32"), "names the limit: {maps}");
+    let hosts = check_limits(0, 65).expect_err("65 hosts cross the limit");
+    assert!(hosts.to_string().contains("64"), "names the limit: {hosts}");
+}
+
+#[test]
+fn validate_limits_accept_exactly_the_limit() {
+    check_limits(32, 64).expect("32 folders and 64 hosts are allowed");
+    check_limits(0, 0).expect("empty is allowed");
+}
+
+// --- Review fixes (tech review of the first run, .draft/deviations.yaml task 2.1) -------------
+//
+// Authored at Run, Red first. They pin what the review found the static set missed: symlinks
+// inside a protected folder (stow-style dotfiles) re-expose their targets, the runtime dir holds
+// the devtools socket (R2.11), and a canonical path can still be unusable as a `--rw-map`.
+
+mod review_fixes {
+    use std::os::unix::ffi::OsStrExt as _;
+    use std::os::unix::fs::symlink;
+    use std::path::{Path, PathBuf};
+
+    use super::{Home, home};
+    use crate::validate::{
+        ForbiddenRule, HostError, ProtectedSet, RwMapError, WalkLimits, WalkStop, validate_host,
+        validate_rw_map, validate_rw_map_with,
+    };
+
+    fn set(h: &Home) -> ProtectedSet {
+        ProtectedSet::new(&h.home, &h.config, None)
+    }
+
+    fn forbidden(set: &ProtectedSet, raw: &str) -> ForbiddenRule {
+        match validate_rw_map_with(raw, set) {
+            Err(error @ RwMapError::Forbidden { .. }) => {
+                let text = error.to_string();
+                assert!(text.contains(raw), "names the entry: {text}");
+                match error {
+                    RwMapError::Forbidden { rule, .. } => rule,
+                    _ => unreachable!(),
+                }
+            }
+            Err(other) => panic!("{raw:?} must be refused by a forbidden rule, got {other}"),
+            Ok(path) => panic!("{raw:?} must be refused, accepted as {}", path.display()),
+        }
+    }
+
+    fn mkdir(path: &Path) {
+        std::fs::create_dir_all(path).expect("fixture dir");
+    }
+
+    #[test]
+    fn validate_review_stow_symlink_inside_config_protects_its_target() {
+        let h = home();
+        mkdir(&h.home.join("dotfiles/zeo"));
+        std::fs::write(h.home.join("dotfiles/zeo/settings.json"), "{}").expect("fixture file");
+        symlink(
+            h.home.join("dotfiles/zeo/settings.json"),
+            h.config.join("settings.json"),
+        )
+        .expect("symlink");
+        let set = set(&h);
+        for raw in ["~/dotfiles", "~/dotfiles/zeo"] {
+            match forbidden(&set, raw) {
+                ForbiddenRule::LinkTarget { target, link } => {
+                    assert_eq!(target, h.home.join("dotfiles/zeo/settings.json"));
+                    assert_eq!(link, h.config.join("settings.json"));
+                }
+                other => panic!("{raw:?}: expected LinkTarget, got {other:?}"),
+            }
+        }
+        validate_rw_map_with("~/projects/app", &set).expect("an unrelated folder stays accepted");
+        // The authored entry point builds the same set.
+        assert!(validate_rw_map("~/dotfiles", &h.home, &h.config).is_err());
+    }
+
+    #[test]
+    fn validate_review_symlinked_dirs_are_walked_for_further_links() {
+        let h = home();
+        mkdir(&h.home.join("store/plugins"));
+        mkdir(&h.home.join("vault/keys"));
+        symlink(h.home.join("store/plugins"), h.home.join(".claude/plugins")).expect("symlink");
+        symlink(h.home.join("vault/keys"), h.home.join("store/plugins/keys")).expect("symlink");
+        let set = set(&h);
+        forbidden(&set, "~/store");
+        forbidden(&set, "~/vault");
+        forbidden(&set, "~/vault/keys");
+    }
+
+    #[test]
+    fn validate_review_a_symlink_cycle_terminates() {
+        let h = home();
+        mkdir(&h.home.join("ring/a"));
+        mkdir(&h.home.join("ring/b"));
+        symlink(h.home.join("ring/a"), h.home.join(".ssh/to-a")).expect("symlink");
+        symlink(h.home.join("ring/b"), h.home.join("ring/a/to-b")).expect("symlink");
+        symlink(h.home.join("ring/a"), h.home.join("ring/b/to-a")).expect("symlink");
+        symlink(h.home.join(".ssh"), h.home.join("ring/b/to-ssh")).expect("symlink");
+        let set = set(&h);
+        forbidden(&set, "~/ring/b");
+        validate_rw_map_with("~/cache", &set).expect("the set completed");
+    }
+
+    #[test]
+    fn validate_review_a_protected_tree_too_large_or_too_deep_refuses_every_map() {
+        let h = home();
+        for index in 0..10 {
+            std::fs::write(h.home.join(format!(".claude/file-{index}")), "").expect("fixture file");
+        }
+        let large = ProtectedSet::with_limits(
+            &h.home,
+            &h.config,
+            None,
+            WalkLimits {
+                max_entries: 5,
+                max_depth: 32,
+            },
+        );
+        match forbidden(&large, "~/projects/app") {
+            ForbiddenRule::ProtectedSetIncomplete {
+                stop: WalkStop::TooManyEntries { limit: 5 },
+                ..
+            } => {}
+            other => panic!("expected TooManyEntries, got {other:?}"),
+        }
+
+        mkdir(&h.home.join(".gnupg/a/b/c/d"));
+        let deep = ProtectedSet::with_limits(
+            &h.home,
+            &h.config,
+            None,
+            WalkLimits {
+                max_entries: 1_000,
+                max_depth: 2,
+            },
+        );
+        match forbidden(&deep, "~/cache") {
+            ForbiddenRule::ProtectedSetIncomplete {
+                stop: WalkStop::TooDeep { limit: 2 },
+                ..
+            } => {}
+            other => panic!("expected TooDeep, got {other:?}"),
+        }
+        assert_eq!(WalkLimits::DEFAULT.max_entries, 250_000);
+        assert_eq!(WalkLimits::DEFAULT.max_depth, 32);
+    }
+
+    #[test]
+    fn validate_review_dangling_links_protect_their_future_target() {
+        let h = home();
+        symlink(
+            h.home.join("future/theme"),
+            h.config.join("themes/pending.json"),
+        )
+        .expect("symlink");
+        std::fs::remove_dir_all(h.home.join(".gnupg")).expect("remove .gnupg");
+        symlink(h.home.join("keys/gnupg"), h.home.join(".gnupg")).expect("symlink");
+        let set = set(&h);
+        mkdir(&h.home.join("future/theme"));
+        mkdir(&h.home.join("keys/gnupg"));
+        forbidden(&set, "~/future");
+        forbidden(&set, "~/keys");
+        forbidden(&set, "~/keys/gnupg");
+    }
+
+    #[test]
+    fn validate_review_ancestors_of_home_name_the_above_home_rule() {
+        let h = home();
+        let set = set(&h);
+        let above = h.root.display().to_string();
+        match forbidden(&set, &above) {
+            ForbiddenRule::AboveHome { home } => assert_eq!(home, h.home),
+            other => panic!("expected AboveHome, got {other:?}"),
+        }
+        assert_eq!(forbidden(&set, "/"), ForbiddenRule::Root);
+        assert_eq!(forbidden(&set, "~"), ForbiddenRule::Home);
+    }
+
+    #[test]
+    fn validate_review_unusable_canonical_paths_are_refused() {
+        let h = home();
+        let bad = h.root.join(std::ffi::OsStr::from_bytes(b"bad\xff"));
+        mkdir(&bad);
+        symlink(&bad, h.home.join("link-to-bad")).expect("symlink");
+        symlink(h.home.join("a:b"), h.home.join("link-to-colon")).expect("symlink");
+        let set = set(&h);
+        match validate_rw_map_with("~/link-to-bad", &set) {
+            Err(error @ RwMapError::NotUtf8 { .. }) => {
+                assert!(error.to_string().contains("~/link-to-bad"), "{error}")
+            }
+            other => panic!("expected NotUtf8, got {other:?}"),
+        }
+        match validate_rw_map_with("~/link-to-colon", &set) {
+            Err(error @ RwMapError::ColonInTarget { .. }) => {
+                assert!(error.to_string().contains("~/link-to-colon"), "{error}")
+            }
+            other => panic!("expected ColonInTarget, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn validate_review_a_file_is_refused_as_a_writable_folder() {
+        let h = home();
+        std::fs::write(h.home.join("notes.txt"), "").expect("fixture file");
+        match validate_rw_map_with("~/notes.txt", &set(&h)) {
+            Err(error @ RwMapError::NotADirectory { .. }) => {
+                let text = error.to_string();
+                assert!(text.contains("~/notes.txt"), "{text}");
+                assert!(text.contains("folder"), "names the rule: {text}");
+            }
+            other => panic!("expected NotADirectory, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn validate_review_missing_carries_the_io_kind() {
+        let h = home();
+        match validate_rw_map_with("~/nope", &set(&h)) {
+            Err(RwMapError::Missing { entry, kind }) => {
+                assert_eq!(entry, "~/nope");
+                assert_eq!(kind, std::io::ErrorKind::NotFound);
+            }
+            other => panic!("expected Missing, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn validate_review_localhost_names_are_refused() {
+        for raw in [
+            "foo.localhost",
+            "a.b.localhost",
+            "localhost.localdomain",
+            "LocalHost",
+            "app.LOCALHOST",
+        ] {
+            match validate_host(raw) {
+                Err(error @ HostError::Localhost { .. }) => {
+                    assert!(error.to_string().contains(raw), "{error}")
+                }
+                other => panic!("{raw:?}: expected Localhost, got {other:?}"),
+            }
+        }
+        validate_host("localhost.example.com").expect("a look-alike stays accepted");
+        validate_host("mylocalhost.dev").expect("a look-alike stays accepted");
+    }
+
+    #[test]
+    fn validate_review_runtime_dir_is_protected_in_full() {
+        let h = home();
+        let runtime = h.root.join("run/user/1000");
+        mkdir(&runtime.join("zeo"));
+        std::fs::write(runtime.join("zeo/devtools.sock"), "").expect("fixture file");
+        let set = ProtectedSet::new(&h.home, &h.config, Some(&runtime));
+        for raw in [
+            runtime.display().to_string(),
+            runtime.join("zeo").display().to_string(),
+            h.root.join("run").display().to_string(),
+        ] {
+            match forbidden(&set, &raw) {
+                ForbiddenRule::RuntimeDir { dir } => assert_eq!(dir, runtime),
+                other => panic!("{raw:?}: expected RuntimeDir, got {other:?}"),
+            }
+        }
+        assert!(set.paths().any(|path| path == runtime));
+    }
+
+    #[test]
+    fn validate_review_protected_set_answers_for_a_project_root() {
+        let h = home();
+        let set = set(&h);
+        assert_eq!(set.rule_for(&h.home.join("projects/app")), None);
+        assert_eq!(set.rule_for(&h.home), Some(ForbiddenRule::Home));
+        match set.rule_for(&h.home.join(".config")) {
+            Some(ForbiddenRule::Protected { protected }) => assert_eq!(protected, h.config),
+            other => panic!("expected Protected, got {other:?}"),
+        }
+        assert!(
+            set.rule_for(&h.home.join(".ssh/sub")).is_some(),
+            "a root inside the set is refused"
+        );
+        let paths: Vec<PathBuf> = set.paths().map(Path::to_path_buf).collect();
+        for expected in [
+            h.config.clone(),
+            h.home.join(".claude"),
+            h.home.join(".claude.json"),
+            h.home.join(".ssh"),
+            h.home.join(".gnupg"),
+        ] {
+            assert!(paths.contains(&expected), "{expected:?} in {paths:?}");
+        }
+    }
+
+    #[test]
+    fn validate_review_new_rules_have_their_own_variants_and_texts() {
+        let h = home();
+        let runtime = h.root.join("run");
+        mkdir(&runtime);
+        std::fs::write(h.home.join("notes.txt"), "").expect("fixture file");
+        let set = ProtectedSet::new(&h.home, &h.config, Some(&runtime));
+        let root = h.root.display().to_string();
+        let run = runtime.display().to_string();
+        let texts: Vec<String> = [
+            ("/", "root"),
+            ("~", "home"),
+            (root.as_str(), "home"),
+            ("~/.ssh", "protected"),
+            (run.as_str(), "runtime"),
+            ("~/notes.txt", "folder"),
+        ]
+        .into_iter()
+        .map(|(raw, keyword)| {
+            let text = validate_rw_map_with(raw, &set)
+                .expect_err("refused")
+                .to_string();
+            assert!(text.contains(raw), "names the entry: {text}");
+            assert!(
+                text.to_lowercase().contains(keyword),
+                "names the rule ({keyword}): {text}"
+            );
+            text
+        })
+        .collect();
+        for (index, text) in texts.iter().enumerate() {
+            for other in &texts[index + 1..] {
+                assert_ne!(text, other);
+            }
+        }
+    }
+}
diff --git a/crates/agent_sandbox/test-data/ai-jail-2.8.1-help.txt b/crates/agent_sandbox/test-data/ai-jail-2.8.1-help.txt
new file mode 100644
index 0000000..02967e3
--- /dev/null
+++ b/crates/agent_sandbox/test-data/ai-jail-2.8.1-help.txt
@@ -0,0 +1,131 @@
+ai-jail — sandbox for AI coding agents (bwrap on Linux, sandbox-exec on macOS)
+
+USAGE:
+    ai-jail [OPTIONS] [--] [COMMAND [ARGS...]]
+
+COMMANDS (positional):
+    gemini, claude, codex, opencode, crush, soulforge, grok, pi, jcode,
+    kimi, kiro-cli, bash
+                                            Known AI tool presets
+    status                                 Show current .ai-jail config
+    Any other string                       Passed through as the command
+
+OPTIONS:
+    --rw-map <PATH|SOURCE:DEST>    Mount read-write, optionally at DEST
+                                   (repeatable)
+    --map <PATH|SOURCE:DEST>       Mount read-only, optionally at DEST
+                                   (repeatable)
+    --overlay-map <PATH>           Mount PATH copy-on-write: writes go to a side
+                                   layer, PATH itself stays untouched so you can
+                                   diff/promote later (repeatable; Linux/bwrap
+                                   only, read-only on macOS)
+    --hide-dotdir <NAME>           Never mount dotdir NAME (e.g., .my_secrets) (repeatable)
+    --mask <PATH|GLOB>             Replace PATH/glob matches with empty files/tmpfs (repeatable)
+    --deny-path <PATH|GLOB>        Deny access to PATH/glob with permission errors (repeatable)
+    --mask-except <PATH|GLOB>      Do not mask matches; other sandbox rules still apply
+                                   (weakens protection; repeatable; no ! negation)
+    --deny-path-except <PATH|GLOB> Do not deny matches; other sandbox rules still apply
+                                   (weakens protection; repeatable; no ! negation)
+    --private-home / --no-private-home
+                                   Disable/enable automatic host home dotdir passthrough
+    --lockdown / --no-lockdown     Enable/disable strict read-only lockdown mode
+    --landlock / --no-landlock     Enable/disable Landlock LSM (Linux 5.13+, default: on)
+    --seccomp / --no-seccomp       Enable/disable seccomp syscall filter (Linux, default: on)
+    --rlimits / --no-rlimits       Enable/disable resource limits (default: on)
+    --systemd-user / --no-systemd-user
+                                   Expose host systemd --user bus (dangerous; default: off)
+    --no-gpu / --gpu               Disable/enable GPU device passthrough (Linux only)
+    --no-docker / --docker         Disable/enable Docker socket passthrough (grants host root; default: off)
+    --tailscale / --no-tailscale   Enable/disable Tailscale socket passthrough (default: off)
+    --no-display / --display       Disable/enable X11/Wayland passthrough (Linux only)
+    --audio / --no-audio           Enable/disable host audio passthrough
+                                   (PulseAudio/PipeWire sockets + /dev/snd;
+                                   Linux only; default: off)
+    --github / --no-github         Share gh config read-only and the active
+                                   github.com token from host gh (including
+                                   keyring storage); default: off
+    --aws / --no-aws               Enable/disable read-only ~/.aws mount
+                                   (read-only; opt-in; the agent can use these
+                                   credentials; default: off)
+    --kube / --no-kube             Enable/disable read-only ~/.kube mount
+                                   (read-only; opt-in; the agent can use these
+                                   credentials; default: off)
+    --gcloud / --no-gcloud         Enable/disable read-only ~/.config/gcloud
+                                   mount (read-only; opt-in; the agent can use
+                                   these credentials; default: off)
+    --docker-config / --no-docker-config
+                                   Enable/disable read-only ~/.docker/config.json
+                                   mount (read-only; opt-in; the agent can use
+                                   these credentials; default: off)
+    --kvm / --no-kvm               Enable/disable /dev/kvm passthrough for
+                                   hardware virtualization (Linux only;
+                                   default: off)
+    --network / --no-network       Enable/disable unrestricted network access (default: off)
+    --macos-host-ipc / --no-macos-host-ipc
+                                    Enable/disable broad macOS host IPC compatibility (default: off)
+    --x11 / --no-x11               Enable/disable X11 socket passthrough (default: off)
+    --host-shm / --no-host-shm     Enable/disable host shared-memory passthrough (default: off)
+    --terminal-passthrough / --no-terminal-passthrough
+                                     Enable/disable terminal passthrough (default: off)
+    --agent-state / --no-agent-state
+                                    Enable/disable mounting the agent's own state
+                                    dirs (~/.claude, ~/.codex, ~/.claude.json, ...;
+                                    default: on; off under --lockdown; project
+                                    .ai-jail can only disable)
+    --env <NAME[=VALUE]>            Pass environment variable NAME (value copied from
+                                    the host) or NAME=VALUE into the sandbox
+                                    (repeatable; not persisted to .ai-jail).
+                                    A literal NAME=VALUE is visible on ai-jail's
+                                    own argv before launch. Use --env NAME or
+                                    --env-from-file to avoid that exposure;
+                                    --secret keeps a bound HTTP value outside
+                                    the sandbox entirely.
+    --env-from-file <PATH>          Read KEY=VALUE lines from PATH (repeatable; file
+                                    must be user-owned, mode 0600, outside the project;
+                                    applies like --env, which wins on conflicts)
+    --secret <KEY=host>             Keep KEY's real value out of the sandbox: the child
+                                    sees a placeholder; the egress proxy substitutes the
+                                    real value only for requests to host (repeatable;
+                                    requires filtered egress via --allow-host)
+    --inherit-env / --no-inherit-env
+                                    Inherit the full host environment (default: off —
+                                    only a safe allowlist is passed)
+    --update-check / --no-update-check
+                                    Enable/disable the status bar's GitHub update
+                                    check (default: off)
+    --audit-log / --no-audit-log    Enable/disable the launch audit log at
+                                    ~/.local/share/ai-jail/history.jsonl
+                                    (default: off; project .ai-jail cannot enable)
+    --audit-show                   Show the local audit log and exit
+    --audit-verify                 Verify the audit log's hash chain and exit
+    --worktree / --no-worktree     Enable/disable linked Git worktree metadata passthrough
+    --no-mise / --mise             Disable/enable mise integration
+    --no-toolchains / --toolchains Disable/enable dev-toolchain cache
+                                   persistence (rust/cargo, go, node, …;
+                                   default: on; Linux)
+    --ssh / --no-ssh               Share ~/.ssh read-only + forward SSH_AUTH_SOCK (default: off)
+    --pictures / --no-pictures     Share ~/Pictures read-only (default: off)
+    --browser[=PROFILE]            Use browser isolation profile (hard | soft; default hard)
+    --no-browser                   Disable browser auto-detection/profile
+    --save-config / --no-save-config
+                                   Enable/disable automatic .ai-jail writes (default: on)
+    --hide-config / --no-hide-config
+                                   Mask the project .ai-jail file from the agent (default: on)
+    -s, --status-bar[=STYLE]       Set status line theme (pastel | dark | light; default pastel)
+                                   Pastel picks a random pastel palette per session
+    --no-status-bar                Disable persistent status line
+    --exec                         Direct execution mode (no PTY proxy, no status bar)
+    --allow-tcp-port <PORT>        Deprecated and rejected at launch (UDP cannot be
+                                   isolated); use --allow-host for filtered egress
+                                   instead (still parsed for back-compat)
+    --allow-host <HOST>            Allow CONNECT egress to HOST and its subdomains via the
+                                   built-in filtered proxy (repeatable; implies filtered
+                                   network mode; cannot combine with --network)
+    --claude-dir <PATH>            Use PATH as Claude config dir (sets CLAUDE_CONFIG_DIR)
+    --clean                        Ignore project .ai-jail config, start fresh
+    --dry-run                      Print the sandbox command without executing
+    --init                         Create/update .ai-jail config and exit
+    --bootstrap                    Generate smart permission configs for AI tools
+    -v, --verbose                  Show detailed mount info
+    -h, --help                     Show help
+    -V, --version                  Show version
diff --git a/crates/agent_sandbox/tests/real_ai_jail.rs b/crates/agent_sandbox/tests/real_ai_jail.rs
new file mode 100644
index 0000000..f3c1221
--- /dev/null
+++ b/crates/agent_sandbox/tests/real_ai_jail.rs
@@ -0,0 +1,581 @@
+// Story 043, task 2.4 — the real ai-jail accepts the generated argv and enforces the boundary
+// (R1.2, R1.4, R1.5, R2.7, R2.11, R7.1, R7.4; Q12). Integration test of crate `agent_sandbox`
+// (`crates/agent_sandbox/tests/real_ai_jail.rs`).
+//
+// Validation:
+//   AI_JAIL_BIN=/usr/bin/ai-jail cargo test -p agent_sandbox --test real_ai_jail -- --nocapture
+//   AI_JAIL_BIN=/home/otaku/Projetos/Pessoais/zed/ai-jail/fork/target/release/ai-jail \
+//     cargo test -p agent_sandbox --test real_ai_jail -- --nocapture
+// Without `AI_JAIL_BIN` every test prints why it skipped and passes (the binary is a machine
+// fact, not a build fact).
+//
+// Contract (design.md §agent_sandbox::command): the public `agent_sandbox::command::{generate,
+// JailInputs}`, `agent_sandbox::probe::probe` and `project::agent_server_store::AgentServerCommand`.
+// The "agent" is `sh -c …`, so the jail's view is probed by the process the agent would be.
+//
+// Hostile halves first:
+//   - the boundary, wrongly OPEN: writes into `.git` and `.zed` fail, Zeo's settings file and the
+//     devtools bridge socket are not visible, the env value is not on argv; wrongly CLOSED: the
+//     project itself, a configured `--rw-map` and the Claude dir are writable and the writes
+//     persist on the host; the forwarded loopback port answers; the env value arrives by name; an
+//     ai-jail without `--forward-port` gets none and still starts.
+//
+// Every real (non `--dry-run`) run happens inside a tempdir project, with a tempdir Claude dir and
+// a throwaway env value.
+
+use std::io::{Read as _, Write as _};
+use std::net::TcpListener;
+use std::os::unix::fs::FileTypeExt as _;
+use std::os::unix::net::UnixListener;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use agent_sandbox::command::{
+    JailInputs, NO_PROXY_CARRIER, generate, keep_loopback_off_proxy, loopback_no_proxy,
+};
+use agent_sandbox::probe::{self, JailBinary};
+use collections::HashMap;
+use gpui::TestAppContext;
+use project::agent_server_store::AgentServerCommand;
+
+const SECRET: &str = "env-value-043-must-not-be-on-argv";
+
+/// ai-memory's default endpoint port, the one R7.1 forwards.
+const MEMORY_PORT: u16 = 49374;
+
+fn ai_jail() -> Option<PathBuf> {
+    match std::env::var_os("AI_JAIL_BIN") {
+        Some(path) => Some(PathBuf::from(path)),
+        None => {
+            eprintln!("skipped: AI_JAIL_BIN is not set (point it at an ai-jail >= 2.4.0)");
+            None
+        }
+    }
+}
+
+/// Probes `bin` the way the launch path does: version and `--forward-port` support.
+async fn probed(bin: &Path, cx: &mut TestAppContext) -> JailBinary {
+    // A real child answers in real time; without parking the fake clock fires the 2 s timeout first.
+    cx.executor().allow_parking();
+    let binary = probe::probe(bin, &cx.executor())
+        .await
+        .unwrap_or_else(|error| panic!("probe {}: {error}", bin.display()));
+    eprintln!(
+        "probe: {} is ai-jail {}, --forward-port {}",
+        bin.display(),
+        binary.version,
+        if binary.supports_forward_port {
+            "offered"
+        } else {
+            "not offered"
+        }
+    );
+    binary
+}
+
+/// The ports R7.1/R7.4 forward for memory: `port` when the binary offers `--forward-port`, none
+/// otherwise.
+fn memory_ports(binary: &JailBinary, port: u16) -> Vec<u16> {
+    if binary.supports_forward_port {
+        vec![port]
+    } else {
+        Vec::new()
+    }
+}
+
+struct Project {
+    _tmp: tempfile::TempDir,
+    root: PathBuf,
+    claude_dir: PathBuf,
+    data: PathBuf,
+}
+
+fn project(with_dot_zed: bool) -> Project {
+    let tmp = tempfile::tempdir().expect("tempdir");
+    let base = tmp.path().canonicalize().expect("canonical");
+    let root = base.join("app");
+    std::fs::create_dir_all(root.join(".git")).expect("project");
+    if with_dot_zed {
+        std::fs::create_dir_all(root.join(".zed")).expect(".zed");
+    }
+    let claude_dir = base.join("claude-jailed");
+    let data = base.join("data");
+    std::fs::create_dir_all(&claude_dir).expect("claude dir");
+    std::fs::create_dir_all(&data).expect("data");
+    Project {
+        _tmp: tmp,
+        root,
+        claude_dir,
+        data,
+    }
+}
+
+fn jailed(
+    bin: &Path,
+    p: &Project,
+    script: &str,
+    ports: &[u16],
+    dry_run: bool,
+) -> AgentServerCommand {
+    let rw = [p.data.clone()];
+    let hosts = ["anthropic.com".to_string()];
+    let agent = AgentServerCommand {
+        path: PathBuf::from("/bin/sh"),
+        args: vec!["-c".into(), script.into()],
+        env: Some(HashMap::from_iter([(
+            "PROBE_SECRET".to_string(),
+            SECRET.to_string(),
+        )])),
+    };
+    let mut cmd = generate(
+        agent,
+        &JailInputs {
+            ai_jail: bin,
+            project_root: &p.root,
+            claude_dir: &p.claude_dir,
+            rw_maps: &rw,
+            allow_hosts: &hosts,
+            forward_ports: ports,
+            env_names: vec!["PROBE_SECRET".into()],
+        },
+    );
+    if dry_run {
+        assert_eq!(cmd.args[0], "--exec");
+        cmd.args.insert(1, "--dry-run".into());
+    }
+    cmd
+}
+
+fn run(p: &Project, cmd: &AgentServerCommand) -> std::process::Output {
+    assert!(
+        !cmd.args.iter().any(|arg| arg.contains(SECRET)),
+        "the env value is never on argv: {:?}",
+        cmd.args
+    );
+    let output = execute(p, &cmd.path, &cmd.args, cmd);
+    if !output.status.success() {
+        eprintln!("which flag broke: {}", which_flag_broke(p, cmd));
+    }
+    output
+}
+
+#[allow(
+    clippy::disallowed_methods,
+    reason = "a synchronous test helper: blocking until the jailed child exits is the point"
+)]
+fn execute(
+    p: &Project,
+    path: &Path,
+    args: &[String],
+    cmd: &AgentServerCommand,
+) -> std::process::Output {
+    Command::new(path)
+        .args(args)
+        .envs(cmd.env.clone().unwrap_or_default())
+        .current_dir(&p.root)
+        .output()
+        .expect("ai-jail runs")
+}
+
+/// Names the jail option ai-jail refuses, by re-running `--dry-run` without each option in turn.
+/// A dry run that accepts the whole argv means the failure happened inside the jail (the script's
+/// `BOUNDARY:` line says what).
+fn which_flag_broke(p: &Project, cmd: &AgentServerCommand) -> String {
+    let separator = cmd
+        .args
+        .iter()
+        .position(|arg| arg == "--")
+        .unwrap_or(cmd.args.len());
+    let (jail, agent) = cmd.args.split_at(separator);
+    let jail: Vec<&String> = jail.iter().filter(|arg| *arg != "--dry-run").collect();
+    let dry = |options: &[&String]| -> bool {
+        let mut args: Vec<String> = Vec::with_capacity(options.len() + agent.len() + 1);
+        args.push("--exec".into());
+        args.push("--dry-run".into());
+        args.extend(
+            options
+                .iter()
+                .filter(|arg| **arg != "--exec")
+                .map(|arg| (*arg).clone()),
+        );
+        args.extend(agent.iter().cloned());
+        execute(p, &cmd.path, &args, cmd).status.success()
+    };
+    if dry(&jail) {
+        return "none: ai-jail accepts the whole argv under --dry-run, so the failure is inside \
+                the jail (see the script's BOUNDARY: line)"
+            .into();
+    }
+    // Options are a flag, optionally followed by one value that does not start with `--`.
+    let mut options: Vec<(usize, usize)> = Vec::new();
+    let mut index = 0;
+    while index < jail.len() {
+        let takes_value = jail
+            .get(index + 1)
+            .is_some_and(|next| !next.starts_with("--"));
+        let end = if takes_value { index + 2 } else { index + 1 };
+        options.push((index, end));
+        index = end;
+    }
+    let culprits: Vec<String> = options
+        .iter()
+        .filter(|(start, _)| jail[*start] != "--exec")
+        .filter(|(start, end)| {
+            let without: Vec<&String> = jail[..*start]
+                .iter()
+                .chain(jail[*end..].iter())
+                .copied()
+                .collect();
+            dry(&without)
+        })
+        .map(|(start, end)| {
+            jail[*start..*end]
+                .iter()
+                .map(|arg| arg.as_str())
+                .collect::<Vec<_>>()
+                .join(" ")
+        })
+        .collect();
+    if culprits.is_empty() {
+        "unknown: no single jail option, removed alone, makes --dry-run accept the argv".into()
+    } else {
+        culprits.join(", ")
+    }
+}
+
+fn report(what: &str, output: &std::process::Output) -> String {
+    format!(
+        "{what}: status {:?}\n--- stdout\n{}\n--- stderr\n{}",
+        output.status.code(),
+        String::from_utf8_lossy(&output.stdout),
+        String::from_utf8_lossy(&output.stderr)
+    )
+}
+
+/// The boundary probe run as the agent. The env value is spelled in two quoted halves so the
+/// script itself never carries it whole and the argv guard in [`run`] stays meaningful.
+fn boundary_script(p: &Project) -> String {
+    r#"
+        fail() { echo "BOUNDARY: $1" >&2; exit 1; }
+        touch ./inside-probe || fail "project not writable"
+        touch "$DATA/rw-probe" || fail "--rw-map not writable"
+        touch "$CLAUDE/claude-probe" || fail "--claude-dir not writable"
+        if touch .git/git-probe 2>/dev/null; then fail ".git writable (--map .git)"; fi
+        if touch .zed/zed-probe 2>/dev/null; then fail ".zed writable (--map .zed)"; fi
+        if test -e "$HOME/.config/zeo/settings.json"; then fail "Zeo settings visible (R2.7)"; fi
+        test "$PROBE_SECRET" = "env-value-043-must-not-be-"'on-argv' || fail "--env NAME value missing"
+        echo BOUNDARY-OK
+    "#
+    .replace("$DATA", &p.data.display().to_string())
+    .replace("$CLAUDE", &p.claude_dir.display().to_string())
+}
+
+#[gpui::test]
+async fn real_ai_jail_boundary_holds_inside_the_jail(cx: &mut TestAppContext) {
+    let Some(bin) = ai_jail() else { return };
+    let binary = probed(&bin, cx).await;
+    let p = project(true);
+    let script = boundary_script(&p);
+    let ports = memory_ports(&binary, MEMORY_PORT);
+    let output = run(&p, &jailed(&bin, &p, &script, &ports, false));
+    let text = report("boundary run", &output);
+    assert!(output.status.success(), "{text}");
+    assert!(
+        String::from_utf8_lossy(&output.stdout).contains("BOUNDARY-OK"),
+        "{text}"
+    );
+    assert!(
+        p.root.join("inside-probe").exists(),
+        "the project write persists on the host"
+    );
+    assert!(
+        p.data.join("rw-probe").exists(),
+        "the --rw-map write persists on the host"
+    );
+    assert!(
+        p.claude_dir.join("claude-probe").exists(),
+        "the Claude dir write persists"
+    );
+    assert!(
+        !p.root.join(".git/git-probe").exists(),
+        ".git untouched on the host"
+    );
+    assert!(
+        !p.root.join(".zed/zed-probe").exists(),
+        ".zed untouched on the host"
+    );
+    if !dirs_settings_exists() {
+        eprintln!(
+            "note: ~/.config/zeo/settings.json does not exist on this host; R2.7 check is vacuous here"
+        );
+    }
+}
+
+fn dirs_settings_exists() -> bool {
+    std::env::var_os("HOME")
+        .map(|home| Path::new(&home).join(".config/zeo/settings.json").exists())
+        .unwrap_or(false)
+}
+
+#[gpui::test]
+async fn real_ai_jail_dry_run_accepts_the_argv_with_and_without_dot_zed(cx: &mut TestAppContext) {
+    let Some(bin) = ai_jail() else { return };
+    let binary = probed(&bin, cx).await;
+    let present = project(true);
+    let ports = memory_ports(&binary, MEMORY_PORT);
+    let output = run(&present, &jailed(&bin, &present, "true", &ports, true));
+    assert!(
+        output.status.success(),
+        "{}",
+        report("--dry-run with .zed", &output)
+    );
+
+    // R1.5 makes Zeo create `.zed` before every jailed start; this records what ai-jail itself
+    // does without it, so the design's open question has a measured answer in the test log.
+    let absent = project(false);
+    let output = run(&absent, &jailed(&bin, &absent, "true", &[], true));
+    eprintln!("{}", report("measurement: --dry-run without .zed", &output));
+}
+
+// Added in v2 (R1.5): a project that had no `.zed` gets it created empty before the start, as
+// `launch::decide` does, and the real run then keeps it read-only like any other `.zed`.
+#[gpui::test]
+async fn real_ai_jail_boundary_holds_after_the_dot_zed_creation(cx: &mut TestAppContext) {
+    let Some(bin) = ai_jail() else { return };
+    let binary = probed(&bin, cx).await;
+    let p = project(false);
+    let dot_zed = p.root.join(".zed");
+    assert!(!dot_zed.exists(), "the project starts without .zed");
+    std::fs::create_dir(&dot_zed).expect("R1.5 creates .zed");
+    let script = boundary_script(&p);
+    let ports = memory_ports(&binary, MEMORY_PORT);
+    let output = run(&p, &jailed(&bin, &p, &script, &ports, false));
+    let text = report("boundary run after the R1.5 .zed creation", &output);
+    assert!(output.status.success(), "{text}");
+    assert!(
+        String::from_utf8_lossy(&output.stdout).contains("BOUNDARY-OK"),
+        "{text}"
+    );
+    assert!(
+        !dot_zed.join("zed-probe").exists(),
+        "the created .zed stays read-only"
+    );
+    assert_eq!(
+        std::fs::read_dir(&dot_zed).expect("read .zed").count(),
+        0,
+        "the created .zed is still empty"
+    );
+}
+
+#[gpui::test]
+async fn real_ai_jail_forwards_the_loopback_port(cx: &mut TestAppContext) {
+    let Some(bin) = ai_jail() else { return };
+    let binary = probed(&bin, cx).await;
+    if !binary.supports_forward_port {
+        eprintln!(
+            "skipped: {} ({}) offers no --forward-port, so there is no port to forward (R7.4 is \
+             covered by real_ai_jail_without_forward_port_emits_none_and_starts)",
+            bin.display(),
+            binary.version
+        );
+        return;
+    }
+    let listener = TcpListener::bind("127.0.0.1:0").expect("listen");
+    let port = listener.local_addr().expect("addr").port();
+    let server = std::thread::spawn(move || {
+        if let Ok((mut stream, _)) = listener.accept() {
+            let _ = stream.write_all(b"pong\n");
+            let mut sink = [0u8; 16];
+            let _ = stream.read(&mut sink);
+        }
+    });
+    let p = project(true);
+    let script = format!(
+        "exec bash -c 'exec 3<>/dev/tcp/127.0.0.1/{port}; read -r line <&3; test \"$line\" = pong && echo FORWARD-OK'"
+    );
+    let output = run(&p, &jailed(&bin, &p, &script, &[port], false));
+    let text = report("--forward-port run", &output);
+    assert!(output.status.success(), "{text}");
+    assert!(
+        String::from_utf8_lossy(&output.stdout).contains("FORWARD-OK"),
+        "{text}"
+    );
+    let _ = server.join();
+}
+
+// Fix round of 10.3 (live proof, defect #1): with filtered egress (`--allow-host`) ai-jail exports
+// its own `http_proxy` and forces `no_proxy` empty, so plain HTTP to a forwarded loopback port went
+// to ai-jail's proxy (HTTP 405) unless the client was told `--noproxy`. Wrapped the way a launch
+// with memory forwarded wraps it, a client honouring the proxy variables reaches the port directly.
+#[gpui::test]
+async fn real_ai_jail_forwarded_loopback_http_bypasses_the_jail_proxy(cx: &mut TestAppContext) {
+    let Some(bin) = ai_jail() else { return };
+    let binary = probed(&bin, cx).await;
+    if !binary.supports_forward_port {
+        eprintln!(
+            "skipped: {} ({}) offers no --forward-port, so no loopback port is forwarded and no \
+             proxy exclusion is added",
+            bin.display(),
+            binary.version
+        );
+        return;
+    }
+    let listener = TcpListener::bind("127.0.0.1:0").expect("listen");
+    let port = listener.local_addr().expect("addr").port();
+    // Never joined: before the fix the request goes to ai-jail's proxy and nothing connects here.
+    std::thread::spawn(move || {
+        if let Ok((mut stream, _)) = listener.accept() {
+            let mut request = [0u8; 1024];
+            let _ = stream.read(&mut request);
+            let _ = stream.write_all(
+                b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\nloopback-ok",
+            );
+        }
+    });
+    let p = project(true);
+    // No `--noproxy`: curl honours the jail's proxy variables, as an MCP HTTP client would.
+    let script = format!(
+        "env | grep -i proxy >&2; \
+         body=$(curl -s --max-time 10 http://127.0.0.1:{port}/mcp); \
+         test \"$body\" = loopback-ok && echo LOOPBACK-OK || {{ echo \"BOUNDARY: got [$body]\" >&2; exit 1; }}"
+    );
+    let mut cmd = keep_loopback_off_proxy(jailed(&bin, &p, &script, &[port], false));
+    cmd.env
+        .get_or_insert_with(HashMap::default)
+        .insert(NO_PROXY_CARRIER.to_string(), loopback_no_proxy([]));
+    let output = run(&p, &cmd);
+    let text = report("forwarded loopback HTTP run", &output);
+    assert!(output.status.success(), "{text}");
+    assert!(
+        String::from_utf8_lossy(&output.stdout).contains("LOOPBACK-OK"),
+        "{text}"
+    );
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("http_proxy=http://127.0.0.1:"),
+        "the jail's proxy is still set (filtered egress), so the bypass is what was proved: {text}"
+    );
+}
+
+// Added in v2 (R7.4): an ai-jail that offers no `--forward-port` (`/usr/bin/ai-jail` 2.8.1) gets a
+// command without it, and the jailed agent still starts.
+#[gpui::test]
+async fn real_ai_jail_without_forward_port_emits_none_and_starts(cx: &mut TestAppContext) {
+    let Some(bin) = ai_jail() else { return };
+    let binary = probed(&bin, cx).await;
+    if binary.supports_forward_port {
+        eprintln!(
+            "skipped: {} ({}) offers --forward-port; R7.4 needs one that does not, such as \
+             /usr/bin/ai-jail 2.8.1",
+            bin.display(),
+            binary.version
+        );
+        return;
+    }
+    let p = project(true);
+    let ports = memory_ports(&binary, MEMORY_PORT);
+    let cmd = jailed(&bin, &p, "echo JAIL-STARTED", &ports, false);
+    let output = run(&p, &cmd);
+    let text = report("run without --forward-port support", &output);
+    assert!(output.status.success(), "{text}");
+    assert!(
+        String::from_utf8_lossy(&output.stdout).contains("JAIL-STARTED"),
+        "{text}"
+    );
+    assert!(
+        !cmd.args.iter().any(|arg| arg == "--forward-port"),
+        "no --forward-port for a binary that lacks it: {:?}",
+        cmd.args
+    );
+}
+
+/// The devtools bridge socket on the host. A live Zeo's socket is used as-is and never touched;
+/// when there is none, a throwaway listener is created and only it (and the `zeo` dir, if this
+/// created it and it is empty) is removed on drop.
+struct BridgeSocket {
+    path: PathBuf,
+    owned: Option<(UnixListener, bool)>,
+}
+
+impl BridgeSocket {
+    fn at(runtime_dir: &Path) -> Self {
+        let dir = runtime_dir.join("zeo");
+        let path = dir.join("devtools.sock");
+        if path.exists() {
+            eprintln!("bridge: using the existing {} as-is", path.display());
+            return Self { path, owned: None };
+        }
+        let created_dir = !dir.exists();
+        if created_dir {
+            std::fs::create_dir(&dir).expect("create the zeo runtime dir");
+        }
+        let listener = UnixListener::bind(&path).expect("bind a throwaway bridge socket");
+        eprintln!("bridge: created a throwaway listener at {}", path.display());
+        Self {
+            path,
+            owned: Some((listener, created_dir)),
+        }
+    }
+}
+
+impl Drop for BridgeSocket {
+    fn drop(&mut self) {
+        let Some((listener, created_dir)) = self.owned.take() else {
+            return;
+        };
+        drop(listener);
+        if let Err(error) = std::fs::remove_file(&self.path) {
+            eprintln!("bridge: could not remove {}: {error}", self.path.display());
+        }
+        if created_dir && let Some(dir) = self.path.parent() {
+            // `remove_dir` refuses a non-empty dir, which is then someone else's and stays.
+            if let Err(error) = std::fs::remove_dir(dir) {
+                eprintln!("bridge: left {} in place: {error}", dir.display());
+            }
+        }
+    }
+}
+
+// Added in v2 (R2.11): the devtools bridge socket exists on the host and is not reachable from the
+// jail, so a jailed agent cannot drive an unjailed thread through it.
+#[gpui::test]
+async fn real_ai_jail_hides_the_devtools_bridge_socket(cx: &mut TestAppContext) {
+    let Some(bin) = ai_jail() else { return };
+    let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from) else {
+        eprintln!("skipped: XDG_RUNTIME_DIR is not set, so there is no bridge socket path");
+        return;
+    };
+    let binary = probed(&bin, cx).await;
+    let bridge = BridgeSocket::at(&runtime_dir);
+    let file_type = std::fs::metadata(&bridge.path)
+        .expect("the bridge socket exists on the host")
+        .file_type();
+    assert!(
+        file_type.is_socket(),
+        "{} is a socket on the host",
+        bridge.path.display()
+    );
+    // The host path is spelled out: the jail's own environment need not carry XDG_RUNTIME_DIR.
+    let script = format!(
+        r#"
+        if test -S '{socket}' || test -e '{socket}'; then
+            echo "BOUNDARY: devtools bridge socket reachable (R2.11)" >&2; exit 1
+        fi
+        echo SOCKET-HIDDEN
+        "#,
+        socket = bridge.path.display()
+    );
+    let p = project(true);
+    let ports = memory_ports(&binary, MEMORY_PORT);
+    let output = run(&p, &jailed(&bin, &p, &script, &ports, false));
+    let text = report("bridge socket run", &output);
+    assert!(output.status.success(), "{text}");
+    assert!(
+        String::from_utf8_lossy(&output.stdout).contains("SOCKET-HIDDEN"),
+        "{text}"
+    );
+    assert!(
+        bridge.path.exists(),
+        "the host socket is untouched by the run"
+    );
+}
diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml
index d091060..37f2bc5 100644
--- a/crates/agent_servers/Cargo.toml
+++ b/crates/agent_servers/Cargo.toml
@@ -29,6 +29,7 @@ doctest = false
 [dependencies]
 acp_thread.workspace = true
 action_log.workspace = true
+agent_sandbox.workspace = true
 async-channel.workspace = true
 agent-client-protocol.workspace = true
 anyhow.workspace = true
@@ -74,7 +75,10 @@ indoc.workspace = true
 acp_thread = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 gpui_tokio.workspace = true
+paths.workspace = true
 project = { workspace = true, features = ["test-support"] }
 reqwest_client = { workspace = true, features = ["test-support"] }
+semver.workspace = true
 settings = { workspace = true, features = ["test-support"] }
 tempfile.workspace = true
+workspace = { workspace = true, features = ["test-support"] }
diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs
index 9a9d16d..eb17d12 100644
--- a/crates/agent_servers/src/acp.rs
+++ b/crates/agent_servers/src/acp.rs
@@ -10,6 +10,7 @@ use debug_log::AcpDebugLog;
 pub use debug_log::{AcpDebugMessage, AcpDebugMessageContent, AcpDebugMessageDirection};
 use session_updates::{InboundSessionNotification, InboundSessionUpdate, PendingSessionUpdates};
 pub use terminal_updates::{DecodedTerminalNotification, DecodedTerminalUpdate};
+pub(crate) use v1::StdioLaunch;
 #[cfg(any(test, feature = "test-support"))]
 pub use v1::test_support;
 pub use v1::{AcpConnection, AcpSessionList, GEMINI_TERMINAL_AUTH_METHOD_ID};
@@ -126,31 +127,53 @@ fn fallback_failure(failure: anyhow::Error, negotiation: anyhow::Error) -> anyho
     failure.context(context)
 }
 
-pub async fn connect(
+pub(crate) async fn connect(
     agent_id: AgentId,
     project: Entity<Project>,
     command: AgentServerCommand,
     agent_server_store: WeakEntity<AgentServerStore>,
     default_mode: Option<acp_v2::SessionConfigValueId>,
     default_config_options: HashMap<String, settings::AgentConfigOptionValue>,
+    launch: StdioLaunch,
     cx: &mut AsyncApp,
 ) -> Result<Rc<dyn acp_thread::AgentConnection>> {
-    let negotiation_error = if cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()) {
-        match AcpV2Connection::stdio(
+    let StdioLaunch { cwd, io_ended } = launch;
+    // Both attempts start the command the same way: a jailed command runs in the root its launch
+    // decision checked, with only its own env, never through a login shell (story 043).
+    let (io_ended, negotiation_error) = if cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()) {
+        // The v2 attempt gets an end signal of its own. A refused negotiation is not the end of
+        // the launch -- the v1 retry below is the same launch -- so the caller's signal is
+        // forwarded only once a v2 connection is kept.
+        let (v2_io_ended, v2_io_ended_rx) = futures::channel::oneshot::channel::<()>();
+        match AcpV2Connection::stdio_with(
             agent_id.clone(),
             project.clone(),
             command.clone(),
             agent_server_store.clone(),
+            StdioLaunch {
+                cwd: cwd.clone(),
+                io_ended: Some(v2_io_ended),
+            },
             cx,
         )
         .await
         {
-            Ok(connection) => return Ok(Rc::new(connection)),
-            Err(error) if error.is::<UnsupportedVersion>() => Some(error),
+            Ok(connection) => {
+                if let Some(io_ended) = io_ended {
+                    cx.background_executor()
+                        .spawn(async move {
+                            v2_io_ended_rx.await.ok();
+                            drop(io_ended);
+                        })
+                        .detach();
+                }
+                return Ok(Rc::new(connection));
+            }
+            Err(error) if error.is::<UnsupportedVersion>() => (io_ended, Some(error)),
             Err(error) => return Err(error),
         }
     } else {
-        None
+        (io_ended, None)
     };
     v1::connect(
         agent_id,
@@ -159,6 +182,7 @@ pub async fn connect(
         agent_server_store,
         default_mode,
         default_config_options,
+        StdioLaunch { cwd, io_ended },
         cx,
     )
     .await
@@ -1445,6 +1469,7 @@ done
                         WeakEntity::new_invalid(),
                         None,
                         HashMap::default(),
+                        StdioLaunch::default(),
                         &mut cx,
                     )
                     .await
@@ -1601,6 +1626,7 @@ done
                         WeakEntity::new_invalid(),
                         None,
                         HashMap::default(),
+                        StdioLaunch::default(),
                         &mut cx,
                     )
                     .await
diff --git a/crates/agent_servers/src/acp/acp_beta_connect_tests.rs b/crates/agent_servers/src/acp/acp_beta_connect_tests.rs
index 4d596de..ff94965 100644
--- a/crates/agent_servers/src/acp/acp_beta_connect_tests.rs
+++ b/crates/agent_servers/src/acp/acp_beta_connect_tests.rs
@@ -120,6 +120,7 @@ async fn connect_beta_once(
                 WeakEntity::new_invalid(),
                 None,
                 HashMap::default(),
+                StdioLaunch::default(),
                 &mut cx,
             )
             .await
diff --git a/crates/agent_servers/src/acp/transport.rs b/crates/agent_servers/src/acp/transport.rs
index 86f3d0c..0dc9ca6 100644
--- a/crates/agent_servers/src/acp/transport.rs
+++ b/crates/agent_servers/src/acp/transport.rs
@@ -7,7 +7,7 @@ use futures::{
 use gpui::{AsyncApp, Entity};
 use project::{Project, agent_server_store::AgentServerCommand};
 use remote::command::RemoteCommand;
-use std::{io, pin::Pin, process::Stdio};
+use std::{io, path::Path, pin::Pin, process::Stdio};
 use task::{Shell, ShellBuilder};
 use util::{ResultExt as _, process::Child};
 
@@ -28,11 +28,34 @@ pub(super) struct StdioProcess {
     pub debug_log: AcpDebugLog,
 }
 
+/// Starts `command` with piped stdio. Without `cwd_override` it runs through the user's shell in
+/// the project's first folder (or on the remote host); with one it runs directly, in exactly
+/// that folder, with only the given env, and only for a local project — a sandboxed start's
+/// working directory is the writable project, so no shell startup file may run (and `cd`) before
+/// it, and ai-jail copies part of its own environment into the jail (story 043).
 pub(super) fn spawn_stdio(
     project: &Entity<Project>,
     command: AgentServerCommand,
+    cwd_override: Option<&Path>,
     cx: &AsyncApp,
 ) -> Result<StdioProcess> {
+    if let Some(cwd) = cwd_override {
+        anyhow::ensure!(
+            project.read_with(cx, |project, _cx| project.is_local()),
+            "a working-directory override is only for a local project"
+        );
+        let path = command.path.display().to_string();
+        let mut child = util::command::new_std_command(&command.path);
+        // Only the given env: this is a jailed start, and ai-jail copies part of its own
+        // environment into the jail.
+        child
+            .env_clear()
+            .args(&command.args)
+            .envs(command.env.unwrap_or_default())
+            .current_dir(cwd);
+        let child = Child::spawn(child, Stdio::piped(), Stdio::piped(), Stdio::piped())?;
+        return into_stdio_process(child, path, &command.args, None);
+    }
     let root_dir = project.read_with(cx, |project, cx| {
         project
             .default_path_list(cx)
@@ -77,8 +100,16 @@ pub(super) fn spawn_stdio(
     }) {
         child.current_dir(cwd);
     }
-    let mut child = Child::spawn(child, Stdio::piped(), Stdio::piped(), Stdio::piped())?;
+    let child = Child::spawn(child, Stdio::piped(), Stdio::piped(), Stdio::piped())?;
+    into_stdio_process(child, path, &arguments, command_input)
+}
 
+fn into_stdio_process(
+    mut child: Child,
+    path: String,
+    arguments: &[String],
+    command_input: Option<Vec<u8>>,
+) -> Result<StdioProcess> {
     let stdout = child.stdout.take().context("Failed to take stdout")?;
     let stdin = child.stdin.take().context("Failed to take stdin")?;
     let stderr = child.stderr.take().context("Failed to take stderr")?;
@@ -184,7 +215,7 @@ mod tests {
                 mut outgoing,
                 stderr,
                 debug_log,
-            } = spawn_stdio(&project, command.clone(), &cx.to_async())
+            } = spawn_stdio(&project, command.clone(), None, &cx.to_async())
                 .expect("spawn fresh transport");
             let stderr_task = cx.background_executor.spawn(stderr);
             let params = serde_json::json!({ "attempt": attempt });
@@ -249,4 +280,137 @@ mod tests {
             }));
         }
     }
+
+    /// Runs `command` through `spawn_stdio` and returns its stdout lines.
+    async fn stdout_of(
+        project: &Entity<Project>,
+        command: AgentServerCommand,
+        cwd_override: Option<&Path>,
+        cx: &mut TestAppContext,
+    ) -> Vec<String> {
+        let StdioProcess {
+            mut child,
+            mut incoming,
+            outgoing,
+            stderr,
+            ..
+        } = spawn_stdio(project, command, cwd_override, &cx.to_async()).expect("spawn");
+        drop(outgoing);
+        let stderr_task = cx.background_executor.spawn(stderr);
+        let exchange = async {
+            let mut output = Vec::new();
+            while let Some(line) = incoming.next().await {
+                output.push(line.expect("read output"));
+            }
+            child.status().await.expect("wait for process");
+            stderr_task.await.expect("read stderr");
+            output
+        }
+        .fuse();
+        let timeout = cx
+            .background_executor
+            .timer(std::time::Duration::from_secs(5))
+            .fuse();
+        futures::pin_mut!(exchange, timeout);
+        futures::select! {
+            output = exchange => output,
+            _ = timeout => panic!("timed out waiting for the spawned process"),
+        }
+    }
+
+    fn pwd() -> AgentServerCommand {
+        AgentServerCommand {
+            path: "/bin/pwd".into(),
+            args: vec!["-P".into()],
+            env: None,
+        }
+    }
+
+    // Story 043 (review M1): a jailed start runs in the root the launch decision checked, not
+    // in whatever the project's first folder is by the time the process spawns.
+    #[gpui::test]
+    async fn spawn_stdio_runs_in_the_cwd_override(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+        cx.executor().allow_parking();
+        let project_dir = tempfile::tempdir().expect("project directory");
+        let checked_root = tempfile::tempdir().expect("checked root");
+        let expected = checked_root
+            .path()
+            .canonicalize()
+            .expect("resolve checked root");
+        let project = Project::example([project_dir.path()], &mut cx.to_async()).await;
+
+        let output = stdout_of(&project, pwd(), Some(&expected), cx).await;
+        assert_eq!(output, [expected.to_string_lossy().into_owned()]);
+    }
+
+    // Story 043 (review L3): ai-jail binds its working directory as the writable project, so
+    // nothing may run between the spawn and ai-jail — a shell startup file (`BASH_ENV` for
+    // bash, `$ZDOTDIR/.zshenv` for zsh) that changes directory would move the jail elsewhere.
+    #[gpui::test]
+    async fn spawn_stdio_with_a_cwd_override_skips_the_shell(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+        cx.executor().allow_parking();
+        let project_dir = tempfile::tempdir().expect("project directory");
+        let checked_root = tempfile::tempdir().expect("checked root");
+        let expected = checked_root
+            .path()
+            .canonicalize()
+            .expect("resolve checked root");
+        let startup = tempfile::tempdir().expect("startup files");
+        let bash_env = startup.path().join("bash_env");
+        std::fs::write(&bash_env, "cd /\n").expect("write BASH_ENV");
+        std::fs::write(startup.path().join(".zshenv"), "cd /\n").expect("write .zshenv");
+        let project = Project::example([project_dir.path()], &mut cx.to_async()).await;
+        let mut command = pwd();
+        command.env = Some(HashMap::from_iter([
+            (
+                "BASH_ENV".to_string(),
+                bash_env.to_string_lossy().into_owned(),
+            ),
+            (
+                "ZDOTDIR".to_string(),
+                startup.path().to_string_lossy().into_owned(),
+            ),
+        ]));
+
+        let output = stdout_of(&project, command, Some(&expected), cx).await;
+        assert_eq!(
+            output,
+            [expected.to_string_lossy().into_owned()],
+            "a startup file of the user's shell ran before the agent (SHELL={:?})",
+            std::env::var("SHELL").ok()
+        );
+    }
+
+    // Story 043 (group-3 review F1): the override path is a jailed start, and ai-jail copies part
+    // of its own environment into the jail, so it starts from nothing but the given env.
+    #[gpui::test]
+    async fn spawn_stdio_with_a_cwd_override_starts_from_an_empty_environment(
+        cx: &mut TestAppContext,
+    ) {
+        cx.update(|cx| {
+            let settings_store = settings::SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+        cx.executor().allow_parking();
+        let project_dir = tempfile::tempdir().expect("project directory");
+        let project = Project::example([project_dir.path()], &mut cx.to_async()).await;
+        let command = AgentServerCommand {
+            path: "/usr/bin/env".into(),
+            args: Vec::new(),
+            env: Some(HashMap::from_iter([(
+                "ONLY_THIS".to_string(),
+                "1".to_string(),
+            )])),
+        };
+        let output = stdout_of(&project, command, Some(project_dir.path()), cx).await;
+        assert_eq!(output, ["ONLY_THIS=1".to_string()]);
+    }
 }
diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs
index 78b568f..6ff2ec7 100644
--- a/crates/agent_servers/src/acp/v1.rs
+++ b/crates/agent_servers/src/acp/v1.rs
@@ -421,22 +421,35 @@ impl AgentSessionList for AcpSessionList {
     }
 }
 
-pub async fn connect(
+/// How [`connect`] starts the agent process, beyond its command.
+#[derive(Default)]
+pub(crate) struct StdioLaunch {
+    /// The working directory, instead of the project's first folder: a jailed start runs in the
+    /// root its launch decision checked (story 043).
+    pub cwd: Option<PathBuf>,
+    /// Held by the connection's IO task and dropped when it ends — the agent exited or the
+    /// connection was dropped — so the receiver learns that the launch is over.
+    pub io_ended: Option<futures::channel::oneshot::Sender<()>>,
+}
+
+pub(crate) async fn connect(
     agent_id: AgentId,
     project: Entity<Project>,
     command: AgentServerCommand,
     agent_server_store: WeakEntity<AgentServerStore>,
     default_mode: Option<acp_v2::SessionConfigValueId>,
     default_config_options: HashMap<String, AgentConfigOptionValue>,
+    launch: StdioLaunch,
     cx: &mut AsyncApp,
 ) -> Result<Rc<dyn AgentConnection>> {
-    let conn = AcpConnection::stdio(
+    let conn = AcpConnection::stdio_with(
         agent_id,
         project,
-        command.clone(),
+        command,
         agent_server_store,
         default_mode,
         default_config_options,
+        launch,
         cx,
     )
     .await?;
@@ -602,6 +615,9 @@ impl AcpConnection {
         self.debug_log.subscribe()
     }
 
+    /// Starts `command` in the project's first folder. Agents started by the app go through
+    /// `CustomAgentServer::connect`, which decides the sandbox first.
+    #[cfg(any(test, feature = "test-support"))]
     pub async fn stdio(
         agent_id: AgentId,
         project: Entity<Project>,
@@ -611,6 +627,30 @@ impl AcpConnection {
         default_config_options: HashMap<String, AgentConfigOptionValue>,
         cx: &mut AsyncApp,
     ) -> Result<Self> {
+        Self::stdio_with(
+            agent_id,
+            project,
+            command,
+            agent_server_store,
+            default_mode,
+            default_config_options,
+            StdioLaunch::default(),
+            cx,
+        )
+        .await
+    }
+
+    pub(crate) async fn stdio_with(
+        agent_id: AgentId,
+        project: Entity<Project>,
+        command: AgentServerCommand,
+        agent_server_store: WeakEntity<AgentServerStore>,
+        default_mode: Option<acp_v2::SessionConfigValueId>,
+        default_config_options: HashMap<String, AgentConfigOptionValue>,
+        launch: StdioLaunch,
+        cx: &mut AsyncApp,
+    ) -> Result<Self> {
+        let StdioLaunch { cwd, io_ended } = launch;
         let original_command = command.clone();
         let transport::StdioProcess {
             child,
@@ -618,7 +658,7 @@ impl AcpConnection {
             outgoing,
             stderr,
             debug_log,
-        } = transport::spawn_stdio(&project, command, cx)?;
+        } = transport::spawn_stdio(&project, command, cwd.as_deref(), cx)?;
         let mut child = transport::OwnedChild(child);
         let sessions = Rc::new(RefCell::new(HashMap::default()));
 
@@ -663,7 +703,14 @@ impl AcpConnection {
             connection_tx,
             runtime::shutdown_requested(shutdown),
         );
-        runtime.start_io(connection_future, cx);
+        runtime.start_io(
+            async move {
+                // Dropped with this future, whether it completes or the task is cancelled.
+                let _io_ended = io_ended;
+                connection_future.await
+            },
+            cx,
+        );
 
         let connection_rx = async move {
             connection_rx
diff --git a/crates/agent_servers/src/acp/v2.rs b/crates/agent_servers/src/acp/v2.rs
index 4cfbb44..2ab367d 100644
--- a/crates/agent_servers/src/acp/v2.rs
+++ b/crates/agent_servers/src/acp/v2.rs
@@ -1,7 +1,7 @@
 use super::{
     AcpDebugLog, AcpDebugMessage, AcpSession, ClientContext, ConfigOptions,
     ContextServerConfiguration, ForegroundWork, ModeTrigger, PendingSessionUpdates,
-    ReadOnlyConfigOptions, UnsupportedVersion, apply_v2_session_notification,
+    ReadOnlyConfigOptions, StdioLaunch, UnsupportedVersion, apply_v2_session_notification,
     close_undelivered_session, drain_foreground_queue, enqueue_notification,
     first_class_terminal_auth_task, is_disconnect_error, is_resource_not_found, map_acp_error,
     mcp_configurations_for_project, runtime, transport, with_v2_client_ingress,
@@ -221,6 +221,8 @@ struct StdioStartup {
     stderr_task: Shared<Task<()>>,
     runtime: Rc<RefCell<Option<ConnectionRuntime>>>,
     drain: Option<futures::channel::oneshot::Sender<LocalBoxFuture<'static, ()>>>,
+    /// Held by the connection's IO task, like the v1 transport's `StdioLaunch::io_ended`.
+    io_ended: Option<futures::channel::oneshot::Sender<()>>,
 }
 
 impl AcpV2Connection {
@@ -231,13 +233,36 @@ impl AcpV2Connection {
         agent_server_store: WeakEntity<AgentServerStore>,
         cx: &mut AsyncApp,
     ) -> Result<Self> {
+        Self::stdio_with(
+            agent_id,
+            project,
+            command,
+            agent_server_store,
+            StdioLaunch::default(),
+            cx,
+        )
+        .await
+    }
+
+    /// [`Self::stdio`] with the launch a sandboxed start needs, on the same terms as the v1
+    /// transport's `stdio_with`: a `cwd` starts the command directly in that folder with only
+    /// its own env (no login shell), and `io_ended` is held by the connection's IO task.
+    pub(crate) async fn stdio_with(
+        agent_id: AgentId,
+        project: Entity<Project>,
+        command: AgentServerCommand,
+        agent_server_store: WeakEntity<AgentServerStore>,
+        launch: StdioLaunch,
+        cx: &mut AsyncApp,
+    ) -> Result<Self> {
+        let StdioLaunch { cwd, io_ended } = launch;
         let transport::StdioProcess {
             child,
             incoming,
             outgoing,
             stderr,
             debug_log,
-        } = transport::spawn_stdio(&project, command, cx)?;
+        } = transport::spawn_stdio(&project, command, cwd.as_deref(), cx)?;
         let mut child = transport::OwnedChild(child);
         let status = child
             .0
@@ -279,6 +304,7 @@ impl AcpV2Connection {
                         stderr_task,
                         runtime,
                         drain: Some(drain_sender),
+                        io_ended,
                     }),
                     &mut cx,
                 )
@@ -323,18 +349,20 @@ impl AcpV2Connection {
         startup: Option<StdioStartup>,
         cx: &mut AsyncApp,
     ) -> Result<Self> {
-        let (runtime, child, stderr_task, drain_sender) = match startup {
+        let (runtime, child, stderr_task, drain_sender, io_ended) = match startup {
             Some(startup) => (
                 startup.runtime,
                 Some(startup.child),
                 startup.stderr_task,
                 startup.drain,
+                startup.io_ended,
             ),
             None => (
                 Rc::new(RefCell::new(None)),
                 None,
                 Task::ready(()).shared(),
                 None,
+                None,
             ),
         };
         let request_elicitations = cx.new(|_| ElicitationStore::default());
@@ -366,7 +394,15 @@ impl AcpV2Connection {
                     )
                     .await
                 });
-        client_runtime.start_io(runtime::finish_connection(connection_future, cleanup), cx);
+        let connection_future = runtime::finish_connection(connection_future, cleanup);
+        client_runtime.start_io(
+            async move {
+                // Dropped with this future, whether it completes or the task is cancelled.
+                let _io_ended = io_ended;
+                connection_future.await
+            },
+            cx,
+        );
         let stderr_for_drain = stderr_task.clone();
         *runtime.borrow_mut() = Some(ConnectionRuntime {
             _client: client_runtime,
@@ -8005,6 +8041,7 @@ exec sleep 60
                     stderr_task: Task::ready(()).shared(),
                     runtime: Rc::new(RefCell::new(None)),
                     drain: None,
+                    io_ended: None,
                 }),
                 &mut cx,
             )
diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs
index 1dfc726..017f603 100644
--- a/crates/agent_servers/src/agent_servers.rs
+++ b/crates/agent_servers/src/agent_servers.rs
@@ -1,6 +1,12 @@
 mod acp;
 mod custom;
 #[cfg(test)]
+mod sandbox_hook_lifecycle_tests;
+#[cfg(test)]
+mod sandbox_hook_tests;
+#[cfg(test)]
+mod sandbox_hook_v2_tests;
+#[cfg(test)]
 mod test_log_capture;
 pub mod thread_config_store;
 
diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs
index 3992b03..6851a2f 100644
--- a/crates/agent_servers/src/custom.rs
+++ b/crates/agent_servers/src/custom.rs
@@ -1,6 +1,8 @@
+use crate::acp::StdioLaunch;
 use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
 use acp_thread::AgentConnection;
 use agent_client_protocol::v2::schema as acp_v2;
+use agent_sandbox::launch::{self, LaunchDecision, LaunchError, LaunchRecord, LaunchRegistry};
 use anyhow::{Context as _, Result};
 use collections::HashSet;
 use fs::Fs;
@@ -264,20 +266,63 @@ impl AgentServer for CustomAgentServer {
                     extra_env.insert("GEMINI_API_KEY".into(), api_key);
                 }
             }
-            let command = store
-                .update(cx, |store, cx| {
-                    let agent = store.get_external_agent(&agent_id).with_context(|| {
-                        format!("Custom agent server `{}` is not registered", agent_id)
-                    })?;
-                    if let Some(new_version_available_tx) = delegate.new_version_available {
-                        agent.set_new_version_available_tx(new_version_available_tx);
-                    }
-                    if let Some(loading_status_tx) = delegate.loading_status {
-                        agent.set_loading_status_tx(loading_status_tx);
-                    }
-                    anyhow::Ok(agent.get_command(vec![], extra_env, &mut cx.to_async()))
-                })??
-                .await?;
+            // Only the names this start configures may be forwarded into a sandbox: the command's
+            // env also carries the project's whole login-shell environment.
+            let mut configured_env: std::collections::HashSet<String> =
+                extra_env.keys().cloned().collect();
+            let (command, agent_env) = store.update(cx, |store, cx| {
+                let agent = store.get_external_agent(&agent_id).with_context(|| {
+                    format!("Custom agent server `{}` is not registered", agent_id)
+                })?;
+                if let Some(new_version_available_tx) = delegate.new_version_available {
+                    agent.set_new_version_available_tx(new_version_available_tx);
+                }
+                if let Some(loading_status_tx) = delegate.loading_status {
+                    agent.set_loading_status_tx(loading_status_tx);
+                }
+                let agent_env = agent.configured_env_names();
+                anyhow::Ok((
+                    agent.get_command(vec![], extra_env, &mut cx.to_async()),
+                    agent_env,
+                ))
+            })??;
+            let command = command.await?;
+            configured_env.extend(agent_env);
+
+            let plan = launch::plan(
+                agent_id.clone(),
+                &project,
+                command.clone(),
+                &configured_env,
+                cx,
+            )
+            .await;
+            let (record, command, cwd, jailed) = match plan.decision {
+                LaunchDecision::Jailed(record, mut jailed_command) => {
+                    let Some(spawn) = plan.jail_spawn else {
+                        anyhow::bail!("agent sandbox: a jailed start came without its spawn plan");
+                    };
+                    jailed_command.env = Some(spawn.env.into_iter().collect());
+                    (record, jailed_command, Some(spawn.cwd), true)
+                }
+                LaunchDecision::Unchanged(record) => (record, command, None, false),
+                LaunchDecision::Refused(error) => {
+                    log_refusal(&error);
+                    // No context: the thread shows `to_string()`, the outermost layer only, and
+                    // the refusal's own text names the root and ends with the R1.9 sentence.
+                    return Err(anyhow::Error::new(error));
+                }
+            };
+            log_launch(&record, jailed);
+            let launch_id = cx.update(|cx| LaunchRegistry::register(record, cx));
+            let (io_ended, launch_ended) = futures::channel::oneshot::channel::<()>();
+            cx.spawn(async move |cx| {
+                // Fires when the connection's IO task ends or the start fails: the sender is
+                // dropped either way.
+                launch_ended.await.ok();
+                cx.update(|cx| LaunchRegistry::remove_launch(launch_id, cx));
+            })
+            .detach();
             let connection = crate::acp::connect(
                 agent_id,
                 project,
@@ -285,6 +330,10 @@ impl AgentServer for CustomAgentServer {
                 store.clone(),
                 default_mode,
                 default_config_options,
+                StdioLaunch {
+                    cwd,
+                    io_ended: Some(io_ended),
+                },
                 cx,
             )
             .await?;
@@ -297,6 +346,58 @@ impl AgentServer for CustomAgentServer {
     }
 }
 
+/// One value of a `key=value` log field: whitespace, `=` and control characters become `_`,
+/// so a hostile agent id cannot forge a field (Q14).
+pub(crate) fn log_token(value: &str) -> String {
+    if value.is_empty() {
+        return "_".to_string();
+    }
+    value
+        .chars()
+        .map(|c| {
+            if c.is_whitespace() || c == '=' || c.is_control() {
+                '_'
+            } else {
+                c
+            }
+        })
+        .collect()
+}
+
+fn version_token(version: Option<&impl std::fmt::Display>) -> String {
+    version.map_or_else(
+        || "-".to_string(),
+        |version| log_token(&version.to_string()),
+    )
+}
+
+/// The R9.1 line of a start: counts and tokens only, never an env value, path or message text
+/// (R9.4).
+fn log_launch(record: &LaunchRecord, jailed: bool) {
+    log::info!(
+        "agent_sandbox event=launch agent={} jailed={jailed} reason={} ai_jail={} rw_maps={} hosts={} forward_ports={}",
+        log_token(&record.agent_id.0),
+        record.reason.as_str(),
+        version_token(record.ai_jail_version.as_ref()),
+        record.rw_maps.len(),
+        record.allow_hosts.len(),
+        record.forward_ports.len(),
+    );
+}
+
+/// The R9.2 line of a refused start: the R9.1 fields at error level, with the cause's token.
+fn log_refusal(error: &LaunchError) {
+    log::error!(
+        "agent_sandbox event=launch agent={} jailed=false reason=refused ai_jail={} rw_maps={} hosts={} forward_ports={} cause={}",
+        log_token(&error.agent_id.0),
+        version_token(error.ai_jail_version.as_ref()),
+        error.rw_maps,
+        error.allow_hosts,
+        error.forward_ports,
+        error.cause.as_str(),
+    );
+}
+
 fn api_key_for_gemini_cli(cx: &mut App) -> Task<Result<String>> {
     let env_var = EnvVar::new("GEMINI_API_KEY".into()).or(EnvVar::new("GOOGLE_AI_API_KEY".into()));
     if let Some(key) = env_var.value {
diff --git a/crates/agent_servers/src/sandbox_hook_lifecycle_tests.rs b/crates/agent_servers/src/sandbox_hook_lifecycle_tests.rs
new file mode 100644
index 0000000..fa327d2
--- /dev/null
+++ b/crates/agent_servers/src/sandbox_hook_lifecycle_tests.rs
@@ -0,0 +1,289 @@
+// Story 043, task 3.2 — what the spawn hook leaves behind: the text a refused start shows, and
+// how long a start's launch record lives (R1.9, R5, review M1). Added after tech review; the
+// launch line and the spawn shapes are `sandbox_hook_tests`.
+//
+// Behaviour-level: real processes on the real filesystem. The agent is a shell script named
+// like the Plus adapter (R1.13) that answers ACP `initialize` and then waits on stdin, so the
+// connection stays up until it is dropped.
+
+use std::os::unix::fs::PermissionsExt as _;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::time::Duration;
+
+use acp_thread::AgentConnection;
+use agent_client_protocol::{schema::ProtocolVersion, v1::schema as acp};
+use agent_sandbox::launch::{FAIL_CLOSED, LaunchRegistry, Reason};
+use fs::RealFs;
+use gpui::{Entity, TestAppContext, UpdateGlobal as _};
+use project::Project;
+use project::agent_server_store::AgentId;
+use serde_json::{Value, json};
+use settings::SettingsStore;
+
+use crate::custom::CustomAgentServer;
+use crate::{AgentServer as _, AgentServerDelegate};
+
+const AGENT: &str = "lifecycle-agent";
+
+struct Fixture {
+    _tmp: tempfile::TempDir,
+    base: PathBuf,
+    root: PathBuf,
+    agent: PathBuf,
+}
+
+impl Fixture {
+    fn new() -> Self {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let base = tmp.path().canonicalize().expect("canonical");
+        let root = base.join("app");
+        std::fs::create_dir_all(root.join(".git")).expect("project");
+        std::fs::create_dir_all(base.join("claude-jailed")).expect("claude dir");
+        let agent = base.join("bin/claude-agent-acp-plus");
+        let initialize = serde_json::to_string(&acp::InitializeResponse::new(ProtocolVersion::V1))
+            .expect("serialize initialize response");
+        std::fs::create_dir_all(agent.parent().expect("parent")).expect("mkdir");
+        std::fs::write(
+            &agent,
+            format!(
+                r#"#!/bin/sh
+IFS= read -r request || exit 2
+id=$(printf '%s' "$request" | sed -n 's/.*"id":\("[^"]*"\|[0-9][0-9]*\).*/\1/p')
+test -n "$id" || exit 3
+printf '{{"jsonrpc":"2.0","id":%s,"result":%s}}\n' "$id" '{initialize}'
+exec cat >/dev/null
+"#
+            ),
+        )
+        .expect("write agent");
+        std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).expect("chmod");
+        Self {
+            _tmp: tmp,
+            base,
+            root,
+            agent,
+        }
+    }
+
+    fn settings(&self, sandbox_on: bool, ai_jail_path: &Path) -> Value {
+        json!({
+            "agent_servers": {
+                AGENT: { "type": "custom", "command": self.agent, "args": [] }
+            },
+            "agent_sandbox": {
+                "enabled": sandbox_on,
+                "ai_jail_path": ai_jail_path,
+                "claude_config_dir": self.base.join("claude-jailed"),
+            },
+            "agent_memory": { "enabled": false }
+        })
+    }
+}
+
+fn init(settings: Value, cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        agent_sandbox::init(cx);
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&settings.to_string(), cx)
+                .result()
+                .expect("user settings parse");
+        });
+    });
+}
+
+async fn start(
+    f: &Fixture,
+    cx: &mut TestAppContext,
+) -> (Entity<Project>, anyhow::Result<Rc<dyn AgentConnection>>) {
+    let project = Project::test(RealFs::new(None, cx.executor()), [f.root.as_path()], cx).await;
+    cx.run_until_parked();
+    let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
+    let delegate = AgentServerDelegate::new(store, None, None);
+    let server = CustomAgentServer::new(AgentId::new(AGENT));
+    let connect = cx.update(|cx| server.connect(delegate, project.clone(), cx));
+    let timeout = cx.background_executor.timer(Duration::from_secs(10));
+    let result = futures::select! {
+        result = futures::FutureExt::fuse(connect) => result,
+        _ = futures::FutureExt::fuse(timeout) => panic!("timed out starting the agent"),
+    };
+    cx.run_until_parked();
+    (project, result)
+}
+
+fn record_reason(f: &Fixture, cx: &mut TestAppContext) -> Option<Reason> {
+    cx.update(|cx| LaunchRegistry::get(&AgentId::new(AGENT), &f.root, cx))
+        .map(|record| record.reason)
+}
+
+// agent_ui shows a failed start as `err.to_string()`, the outermost context only. A context
+// added around the refusal would show instead of it, and the fail-closed sentence (R1.9) and
+// the root would never reach the user.
+#[gpui::test]
+async fn sandbox_refusal_message_is_the_top_level_text(cx: &mut TestAppContext) {
+    let f = Fixture::new();
+    init(f.settings(true, &f.base.join("nowhere/ai-jail")), cx);
+    let (_project, result) = start(&f, cx).await;
+    let Err(error) = result else {
+        panic!("a start without ai-jail must be refused");
+    };
+    let shown = error.to_string();
+    assert!(shown.contains(FAIL_CLOSED), "shown text: {shown}");
+    assert!(
+        shown.contains(&f.root.display().to_string()),
+        "shown text names the root: {shown}"
+    );
+    assert_eq!(
+        record_reason(&f, cx),
+        None,
+        "a refused start registers nothing"
+    );
+}
+
+// The icon and the settings page read the registry; a record that outlives its agent would
+// show a jail state for an agent that no longer runs.
+#[gpui::test]
+async fn sandbox_launch_record_lives_as_long_as_the_connection(cx: &mut TestAppContext) {
+    let f = Fixture::new();
+    init(f.settings(false, &f.base.join("nowhere/ai-jail")), cx);
+    let (_project, result) = start(&f, cx).await;
+    let connection = result.expect("the scripted agent initializes");
+    assert_eq!(
+        record_reason(&f, cx),
+        Some(Reason::Off),
+        "registered while the connection lives"
+    );
+
+    drop(connection);
+    for _ in 0..50 {
+        cx.run_until_parked();
+        if record_reason(&f, cx).is_none() {
+            return;
+        }
+        cx.background_executor
+            .timer(Duration::from_millis(100))
+            .await;
+    }
+    panic!("the launch record outlived its connection");
+}
+
+// Q14: an agent id is user text; one with a space or '=' must not add a field to the line.
+#[test]
+fn sandbox_log_token_cannot_forge_a_field() {
+    assert_eq!(
+        crate::custom::log_token("my agent jailed=true\n\u{7}"),
+        "my_agent_jailed_true__"
+    );
+    assert_eq!(crate::custom::log_token(""), "_");
+    assert_eq!(
+        crate::custom::log_token("claude-agent-acp-plus"),
+        "claude-agent-acp-plus"
+    );
+}
+
+// Group-3 review F1, end to end: the ai-jail process gets only the configured names and Zeo's
+// base, never the test process's own environment (which stands in for a login shell's).
+#[gpui::test]
+async fn sandbox_jail_process_gets_only_the_configured_env_and_zeos_base(cx: &mut TestAppContext) {
+    let f = Fixture::new();
+    let ai_jail = f.base.join("bin/ai-jail");
+    std::fs::write(
+        &ai_jail,
+        format!(
+            "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'ai-jail 2.8.1'; exit 0; fi\n\
+             env > '{}/jail.env'\nprintf '%s\\n' \"$@\" > '{}/jail.args'\nexit 1\n",
+            f.base.display(),
+            f.base.display()
+        ),
+    )
+    .expect("write ai-jail");
+    std::fs::set_permissions(&ai_jail, std::fs::Permissions::from_mode(0o755)).expect("chmod");
+    let mut settings = f.settings(true, &ai_jail);
+    settings["agent_servers"][AGENT]["env"] = json!({ "HOOK_SECRET": "hook-secret-value" });
+    init(settings, cx);
+    let (_project, result) = start(&f, cx).await;
+    assert!(result.is_err(), "the fake ai-jail exits");
+
+    let dump = std::fs::read_to_string(f.base.join("jail.env")).expect("ai-jail was spawned");
+    let env: Vec<(&str, &str)> = dump
+        .lines()
+        .filter_map(|line| line.split_once('='))
+        .collect();
+    // PWD, SHLVL and _ are set by the fake ai-jail's own /bin/sh; NO_PROXY / NO_BROWSER /
+    // ZEO_THREAD are what Zeo itself adds for every agent.
+    let allowed = [
+        "ZEO_THREAD",
+        "HOOK_SECRET",
+        "PATH",
+        "HOME",
+        "XDG_RUNTIME_DIR",
+        "LANG",
+        "PWD",
+        "OLDPWD",
+        "SHLVL",
+        "_",
+        "NO_PROXY",
+        "NO_BROWSER",
+    ];
+    for (name, _) in &env {
+        assert!(allowed.contains(name), "{name} reached ai-jail");
+    }
+    assert!(env.contains(&("HOOK_SECRET", "hook-secret-value")));
+    assert!(
+        env.contains(&("ZEO_THREAD", "1")),
+        "the jailed agent is marked as Zeo's"
+    );
+    // ai-jail clears the environment and passes on only the names given with `--env`, so the
+    // marker reaching ai-jail's process is not enough: it has to be on that list too.
+    let args = std::fs::read_to_string(f.base.join("jail.args")).expect("ai-jail args");
+    let args: Vec<&str> = args.lines().collect();
+    assert!(
+        args.windows(2)
+            .any(|pair| pair[0] == "--env" && pair[1] == "ZEO_THREAD"),
+        "ZEO_THREAD is forwarded into the jail: {args:?}"
+    );
+    let home = paths::home_dir().display().to_string();
+    assert!(env.contains(&("HOME", home.as_str())), "HOME is Zeo's");
+}
+
+// Story 050: Claude Code hooks inherit the agent's environment, and the tray's hook offers to
+// reopen a session only when it carries this marker. A session Zeo never owned has no thread to
+// open, so the marker is how the tray tells the two apart.
+#[gpui::test]
+async fn unjailed_agent_carries_the_zeo_thread_marker(cx: &mut TestAppContext) {
+    let f = Fixture::new();
+    let script = std::fs::read_to_string(&f.agent).expect("read agent");
+    let dump = f.base.join("agent.env");
+    let script = script.replacen(
+        "#!/bin/sh\n",
+        &format!("#!/bin/sh\nenv > '{}'\n", dump.display()),
+        1,
+    );
+    std::fs::write(&f.agent, script).expect("rewrite agent");
+    let mut settings = f.settings(false, &f.base.join("nowhere/ai-jail"));
+    settings["agent_servers"][AGENT]["env"] = json!({ "HOOK_SECRET": "hook-secret-value" });
+    init(settings, cx);
+    let (_project, result) = start(&f, cx).await;
+    result.expect("the scripted agent initializes");
+
+    let dump = std::fs::read_to_string(&dump).expect("the agent was spawned");
+    let env: Vec<(&str, &str)> = dump
+        .lines()
+        .filter_map(|line| line.split_once('='))
+        .collect();
+    // Names only: the agent inherits the test process's environment, which can hold tokens.
+    let names: Vec<&str> = env.iter().map(|(name, _)| *name).collect();
+    assert!(
+        env.contains(&("ZEO_THREAD", "1")),
+        "agent env names: {names:?}"
+    );
+    assert!(
+        env.contains(&("HOOK_SECRET", "hook-secret-value")),
+        "the entry's own env still arrives beside the marker"
+    );
+}
diff --git a/crates/agent_servers/src/sandbox_hook_tests.rs b/crates/agent_servers/src/sandbox_hook_tests.rs
new file mode 100644
index 0000000..be411ce
--- /dev/null
+++ b/crates/agent_servers/src/sandbox_hook_tests.rs
@@ -0,0 +1,395 @@
+// Story 043, task 3.2 — the spawn hook in `CustomAgentServer::connect` and the launch log
+// (R1.1, R1.6, R1.7, R1.9, R9.1, R9.2, R9.4; Q4, Q14). Wired in the crate root
+// `src/agent_servers.rs` as
+//
+//     #[cfg(test)]
+//     mod sandbox_hook_tests;
+//
+// Validation filter: `sandbox` (every test path is `sandbox_hook_tests::…`).
+//
+// Behaviour-level: real processes on the real filesystem. A fake `ai-jail` records its argv, cwd
+// and environment and exits; a fake agent records that it ran and exits. `connect` then fails
+// the ACP handshake, which is expected — the assertions are on who was spawned, how, and what
+// was logged. The hook must not change the error path for a process that dies; only `Refused`
+// must carry the fail-closed text.
+//
+// Contract (design.md §Spawn hook, §launch): `CustomAgentServer::new(AgentId)` +
+// `AgentServer::connect(delegate, project, cx)`; the agent is a `custom` entry under the user
+// settings key `agent_servers`; the sandbox comes from the user key `agent_sandbox` (design
+// §Data Models); `crate::…` names are upstream's. The log line (R9.1):
+//   `agent_sandbox event=launch agent=<id> jailed=<bool> reason=<on|off|own_command|remote|refused>
+//    ai_jail=<ver|-> rw_maps=<n> hosts=<n> forward_ports=<n>` (+ `cause=` at error level).
+//
+// This file installs the crate's only test logger; run without RUST_LOG/ZED_LOG/CI.
+//
+// Hostile halves first:
+//   - refusal, wrongly START: with the sandbox on and no ai-jail, the agent process never runs
+//     and the error carries the fail-closed sentence and the project root.
+//   - own command, wrongly DOUBLE-WRAP: an agent whose command is `…/ai-jail` runs with exactly
+//     its configured argv; the managed ai-jail is never spawned.
+//   - secrets, wrongly LEAK: an agent env value reaches the jailed process's environment but is
+//     neither on its argv nor in any log line.
+
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::os::unix::fs::PermissionsExt as _;
+use std::path::{Path, PathBuf};
+
+use fs::RealFs;
+use gpui::{Entity, TestAppContext, UpdateGlobal as _};
+use project::Project;
+use project::agent_server_store::AgentId;
+use serde_json::{Value, json};
+use settings::SettingsStore;
+
+use crate::custom::CustomAgentServer;
+use crate::{AgentServer as _, AgentServerDelegate};
+
+const AGENT: &str = "fake-agent";
+const SECRET: &str = "secret-value-043-hook";
+
+// --- log capture (one logger for this test binary) ---------------------------------------------
+
+thread_local! {
+    static LINES: RefCell<Option<Vec<(log::Level, String)>>> = const { RefCell::new(None) };
+}
+
+struct Logs;
+
+impl Logs {
+    fn start() -> Self {
+        crate::test_log_capture::install_forwarding(&Capture);
+        LINES.with_borrow_mut(|lines| *lines = Some(Vec::new()));
+        Self
+    }
+    fn launch_lines(&self) -> Vec<(log::Level, String)> {
+        LINES.with_borrow(|lines| {
+            lines
+                .clone()
+                .unwrap_or_default()
+                .into_iter()
+                .filter(|(_, line)| line.contains("agent_sandbox event=launch"))
+                .collect()
+        })
+    }
+    fn all(&self) -> String {
+        LINES.with_borrow(|lines| {
+            lines
+                .clone()
+                .unwrap_or_default()
+                .into_iter()
+                .map(|(_, line)| line)
+                .collect::<Vec<_>>()
+                .join("\n")
+        })
+    }
+}
+
+impl Drop for Logs {
+    fn drop(&mut self) {
+        LINES.with_borrow_mut(|lines| *lines = None);
+    }
+}
+
+struct Capture;
+
+impl log::Log for Capture {
+    fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
+        metadata.level() <= log::max_level()
+    }
+    fn log(&self, record: &log::Record<'_>) {
+        LINES.with_borrow_mut(|lines| {
+            if let Some(lines) = lines {
+                lines.push((record.level(), record.args().to_string()));
+            }
+        });
+    }
+    fn flush(&self) {}
+}
+
+fn kv(line: &str) -> HashMap<String, String> {
+    line.split_whitespace()
+        .filter_map(|token| token.split_once('='))
+        .map(|(key, value)| (key.to_string(), value.to_string()))
+        .collect()
+}
+
+// --- fixture -------------------------------------------------------------------------------------
+
+struct Fixture {
+    _tmp: tempfile::TempDir,
+    base: PathBuf,
+    root: PathBuf,
+    ai_jail: PathBuf,
+    agent: PathBuf,
+    claude_dir: PathBuf,
+}
+
+fn script(path: &Path, body: &str) {
+    std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
+    std::fs::write(path, format!("#!/bin/sh\n{body}\n")).expect("write");
+    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
+}
+
+impl Fixture {
+    fn new() -> Self {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let base = tmp.path().canonicalize().expect("canonical");
+        let root = base.join("app");
+        std::fs::create_dir_all(root.join(".git")).expect("project");
+        let ai_jail = base.join("bin/ai-jail");
+        script(
+            &ai_jail,
+            &format!(
+                "if [ \"$1\" = --version ]; then echo 'ai-jail 2.8.1'; exit 0; fi\n\
+                 printf '%s\\n' \"$@\" > '{out}/jail.argv'\n\
+                 pwd > '{out}/jail.cwd'\n\
+                 env > '{out}/jail.env'\n\
+                 exit 1",
+                out = base.display()
+            ),
+        );
+        let agent = base.join("bin/claude-agent-acp-plus");
+        script(
+            &agent,
+            &format!(
+                "printf '%s\\n' \"$@\" > '{}/agent.argv'\nexit 1",
+                base.display()
+            ),
+        );
+        let claude_dir = base.join("claude-jailed");
+        std::fs::create_dir_all(&claude_dir).expect("claude dir");
+        Self {
+            _tmp: tmp,
+            base,
+            root,
+            ai_jail,
+            agent,
+            claude_dir,
+        }
+    }
+
+    fn out(&self, name: &str) -> Option<String> {
+        std::fs::read_to_string(self.base.join(name)).ok()
+    }
+
+    fn settings(&self, sandbox_on: bool, ai_jail_path: &Path, agent_command: &Path) -> Value {
+        json!({
+            "agent_servers": {
+                AGENT: {
+                    "type": "custom",
+                    "command": agent_command,
+                    "args": ["--acp", "two words"],
+                    "env": { "HOOK_SECRET": SECRET }
+                }
+            },
+            "agent_sandbox": {
+                "enabled": sandbox_on,
+                "ai_jail_path": ai_jail_path,
+                "claude_config_dir": self.claude_dir,
+                "allow_hosts": ["anthropic.com", "platform.claude.com"],
+            },
+            "agent_memory": { "enabled": false }
+        })
+    }
+}
+
+fn init(settings: Value, cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        agent_sandbox::init(cx);
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&settings.to_string(), cx)
+                .result()
+                .expect("user settings parse");
+        });
+    });
+}
+
+async fn project(f: &Fixture, cx: &mut TestAppContext) -> Entity<Project> {
+    let project = Project::test(RealFs::new(None, cx.executor()), [f.root.as_path()], cx).await;
+    cx.run_until_parked();
+    project
+}
+
+async fn connect(project: &Entity<Project>, cx: &mut TestAppContext) -> Result<(), String> {
+    let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
+    let delegate = AgentServerDelegate::new(store, None, None);
+    let server = CustomAgentServer::new(AgentId::new(AGENT));
+    let result = cx
+        .update(|cx| server.connect(delegate, project.clone(), cx))
+        .await;
+    cx.run_until_parked();
+    result.map(|_| ()).map_err(|error| format!("{error:#}"))
+}
+
+// --- refusal (R1.9, R9.2, Q4) -----------------------------------------------------------------------
+
+#[gpui::test]
+async fn sandbox_refusal_never_spawns_the_agent(cx: &mut TestAppContext) {
+    let f = Fixture::new();
+    let missing = f.base.join("nowhere/ai-jail");
+    init(f.settings(true, &missing, &f.agent), cx);
+    let logs = Logs::start();
+    let project = project(&f, cx).await;
+    let error = connect(&project, cx).await.expect_err("refused");
+    assert!(
+        error.contains("The agent was not started without the sandbox."),
+        "{error}"
+    );
+    assert!(
+        error.contains(&f.root.display().to_string()),
+        "names the project root: {error}"
+    );
+    assert!(
+        f.out("agent.argv").is_none(),
+        "the agent never ran unjailed"
+    );
+    assert!(f.out("jail.argv").is_none());
+
+    let lines = logs.launch_lines();
+    assert_eq!(lines.len(), 1, "one launch line: {lines:?}");
+    assert_eq!(lines[0].0, log::Level::Error, "a refusal logs at error");
+    let fields = kv(&lines[0].1);
+    assert_eq!(fields.get("agent").map(String::as_str), Some(AGENT));
+    assert_eq!(fields.get("jailed").map(String::as_str), Some("false"));
+    assert_eq!(fields.get("reason").map(String::as_str), Some("refused"));
+    assert!(
+        fields.contains_key("cause"),
+        "carries the refusal cause: {}",
+        lines[0].1
+    );
+}
+
+// --- own command (R1.7) ---------------------------------------------------------------------------
+
+#[gpui::test]
+async fn sandbox_own_command_runs_exactly_as_configured(cx: &mut TestAppContext) {
+    let f = Fixture::new();
+    let own = f.base.join("own/ai-jail");
+    script(
+        &own,
+        &format!(
+            "printf '%s\\n' \"$@\" > '{}/own.argv'\nexit 1",
+            f.base.display()
+        ),
+    );
+    init(f.settings(true, &f.ai_jail, &own), cx);
+    let logs = Logs::start();
+    let project = project(&f, cx).await;
+    let _ = connect(&project, cx).await;
+    assert_eq!(
+        f.out("own.argv").as_deref(),
+        Some("--acp\ntwo words\n"),
+        "its own argv, no managed options prepended"
+    );
+    assert!(
+        f.out("jail.argv").is_none(),
+        "the managed ai-jail is never spawned"
+    );
+    let lines = logs.launch_lines();
+    assert_eq!(lines.len(), 1, "{lines:?}");
+    assert_eq!(
+        kv(&lines[0].1).get("reason").map(String::as_str),
+        Some("own_command")
+    );
+}
+
+// --- on (R1.1, R9.1, R9.4) ---------------------------------------------------------------------------
+
+#[gpui::test]
+async fn sandbox_on_spawns_through_ai_jail_in_the_project_root(cx: &mut TestAppContext) {
+    let f = Fixture::new();
+    init(f.settings(true, &f.ai_jail, &f.agent), cx);
+    let logs = Logs::start();
+    let project = project(&f, cx).await;
+    let _ = connect(&project, cx).await;
+
+    let argv = f.out("jail.argv").expect("ai-jail was spawned");
+    let args: Vec<&str> = argv.lines().collect();
+    assert_eq!(args.first().copied(), Some("--exec"), "{args:?}");
+    let agent = f.agent.display().to_string();
+    let at = args
+        .iter()
+        .position(|arg| *arg == agent)
+        .expect("agent on argv");
+    assert_eq!(
+        &args[at + 1..],
+        &["--acp", "two words"],
+        "agent args unchanged"
+    );
+    assert!(
+        args.windows(2).any(|pair| pair == ["--env", "HOOK_SECRET"]),
+        "{args:?}"
+    );
+    assert!(!argv.contains(SECRET), "no env value on argv");
+    assert_eq!(
+        f.out("jail.cwd").map(|cwd| cwd.trim().to_string()),
+        Some(f.root.display().to_string())
+    );
+    assert!(
+        f.out("jail.env")
+            .is_some_and(|env| env.contains(&format!("HOOK_SECRET={SECRET}"))),
+        "the value travels in the environment"
+    );
+    assert!(
+        f.out("agent.argv").is_none(),
+        "the agent never ran outside the jail"
+    );
+    assert!(f.root.join(".zed").is_dir(), "R1.5");
+
+    let lines = logs.launch_lines();
+    assert_eq!(lines.len(), 1, "one launch line per start: {lines:?}");
+    assert_eq!(lines[0].0, log::Level::Info);
+    let fields = kv(&lines[0].1);
+    for (key, value) in [
+        ("agent", AGENT),
+        ("jailed", "true"),
+        ("reason", "on"),
+        ("ai_jail", "2.8.1"),
+        ("hosts", "2"),
+        ("forward_ports", "0"),
+    ] {
+        assert_eq!(
+            fields.get(key).map(String::as_str),
+            Some(value),
+            "{key} in {}",
+            lines[0].1
+        );
+    }
+    assert!(
+        fields
+            .get("rw_maps")
+            .is_some_and(|n| n.parse::<usize>().is_ok())
+    );
+    assert!(
+        !logs.all().contains(SECRET),
+        "no env value in any log line (R9.4)"
+    );
+}
+
+// --- off (R1.6) --------------------------------------------------------------------------------------
+
+#[gpui::test]
+async fn sandbox_off_spawns_the_agent_directly(cx: &mut TestAppContext) {
+    let f = Fixture::new();
+    init(f.settings(false, &f.ai_jail, &f.agent), cx);
+    let logs = Logs::start();
+    let project = project(&f, cx).await;
+    let _ = connect(&project, cx).await;
+    assert_eq!(f.out("agent.argv").as_deref(), Some("--acp\ntwo words\n"));
+    assert!(f.out("jail.argv").is_none());
+    assert!(
+        !f.root.join(".zed").exists(),
+        "nothing created for an unjailed start"
+    );
+    let lines = logs.launch_lines();
+    assert_eq!(lines.len(), 1, "{lines:?}");
+    let fields = kv(&lines[0].1);
+    assert_eq!(fields.get("jailed").map(String::as_str), Some("false"));
+    assert_eq!(fields.get("reason").map(String::as_str), Some("off"));
+}
diff --git a/crates/agent_servers/src/sandbox_hook_v2_tests.rs b/crates/agent_servers/src/sandbox_hook_v2_tests.rs
new file mode 100644
index 0000000..c3c77b0
--- /dev/null
+++ b/crates/agent_servers/src/sandbox_hook_v2_tests.rs
@@ -0,0 +1,289 @@
+// A jailed start when acp-beta is on: `acp::connect` tries ACP v2 first and falls back to v1
+// on a version mismatch. Both attempts must start ai-jail the way story 043 starts it -- in the
+// launch root, with only the configured env, never through a login shell -- and the launch
+// record must follow the process that is actually kept. Zeo forces acp-beta on (0001), so
+// this is the path every jailed start takes.
+//
+// Behaviour-level, like `sandbox_hook_lifecycle_tests`: a fake ai-jail on the real filesystem
+// that logs every spawn and answers `initialize` itself.
+
+use std::os::unix::fs::PermissionsExt as _;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::time::Duration;
+
+use acp_thread::AgentConnection;
+use agent_sandbox::launch::LaunchRegistry;
+use fs::RealFs;
+use gpui::{TestAppContext, UpdateGlobal as _};
+use project::Project;
+use project::agent_server_store::AgentId;
+use serde_json::json;
+use settings::SettingsStore;
+
+use crate::acp::{AcpConnection, AcpV2Connection};
+use crate::custom::CustomAgentServer;
+use crate::{AgentServer as _, AgentServerDelegate};
+
+const AGENT: &str = "jailed-v2-agent";
+
+const V1_RESULT: &str = r#""result":{"protocolVersion":1,"agentCapabilities":{},"agentInfo":{"name":"jailed-v1","version":"1"}}"#;
+const V2_RESULT: &str = r#""result":{"protocolVersion":2,"info":{"name":"jailed-v2","version":"1"},"capabilities":{"session":{}}}"#;
+
+/// What `sandbox_jail_process_gets_only_the_configured_env_and_zeos_base` allows: the entry's
+/// env, Zeo's base, and what the fake's own /bin/sh sets.
+const ALLOWED_ENV: &[&str] = &[
+    "ZEO_THREAD",
+    "HOOK_SECRET",
+    "PATH",
+    "HOME",
+    "XDG_RUNTIME_DIR",
+    "LANG",
+    "PWD",
+    "OLDPWD",
+    "SHLVL",
+    "_",
+    "NO_PROXY",
+    "NO_BROWSER",
+];
+
+struct JailFixture {
+    _tmp: tempfile::TempDir,
+    base: PathBuf,
+    root: PathBuf,
+}
+
+impl JailFixture {
+    /// `v2_result` is what the fake answers to a v2 `initialize`; a v1 one is always answered.
+    fn new(v2_result: &str) -> Self {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let base = tmp.path().canonicalize().expect("canonical");
+        let root = base.join("app");
+        std::fs::create_dir_all(root.join(".git")).expect("project");
+        std::fs::create_dir_all(base.join("claude-jailed")).expect("claude dir");
+        std::fs::create_dir_all(base.join("bin")).expect("bin");
+        let script = r#"#!/bin/sh
+if [ "$1" = --version ]; then echo 'ai-jail 2.8.1'; exit 0; fi
+# The capability probe (`--help`) is not a start of the agent.
+if [ "$1" = --help ]; then exit 0; fi
+printf '%s %s\n' "$$" "$(pwd)" >> 'BASE/spawns.log'
+env > "BASE/jail.env.$$"
+while IFS= read -r request; do
+    case "$request" in
+        *'"method":"initialize"'*)
+            id=${request#*\"id\":}; id=${id%%,*}
+            case "$request" in
+                *'"protocolVersion":2'*) printf '{"jsonrpc":"2.0","id":%s,%s}\n' "$id" 'V2_RESULT' ;;
+                *) printf '{"jsonrpc":"2.0","id":%s,%s}\n' "$id" 'V1_RESULT' ;;
+            esac
+            ;;
+    esac
+done
+"#
+        .replace("BASE", &base.display().to_string())
+        .replace("V2_RESULT", v2_result)
+        .replace("V1_RESULT", V1_RESULT);
+        let ai_jail = base.join("bin/ai-jail");
+        std::fs::write(&ai_jail, script).expect("write ai-jail");
+        std::fs::set_permissions(&ai_jail, std::fs::Permissions::from_mode(0o755)).expect("chmod");
+        // The jailed command names the agent; ai-jail is a fake, so it never runs.
+        let agent = base.join("bin/claude-agent-acp-plus");
+        std::fs::write(&agent, "#!/bin/sh\nexit 9\n").expect("write agent");
+        std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).expect("chmod");
+        let settings = json!({
+            "agent_servers": {
+                AGENT: {
+                    "type": "custom",
+                    "command": agent,
+                    "args": [],
+                    "env": { "HOOK_SECRET": "hook-secret-value" }
+                }
+            },
+            "agent_sandbox": {
+                "enabled": true,
+                "ai_jail_path": ai_jail,
+                "claude_config_dir": base.join("claude-jailed"),
+            },
+            "agent_memory": { "enabled": false }
+        });
+        std::fs::write(base.join("settings.json"), settings.to_string()).expect("settings");
+        Self {
+            _tmp: tmp,
+            base,
+            root,
+        }
+    }
+
+    /// Every spawn of the fake, in order: (pid, working directory).
+    fn spawns(&self) -> Vec<(String, String)> {
+        std::fs::read_to_string(self.base.join("spawns.log"))
+            .unwrap_or_default()
+            .lines()
+            .filter_map(|line| line.split_once(' '))
+            .map(|(pid, cwd)| (pid.to_string(), cwd.to_string()))
+            .collect()
+    }
+
+    fn assert_cleared_env(&self, pid: &str) {
+        let dump = std::fs::read_to_string(self.base.join(format!("jail.env.{pid}")))
+            .expect("the fake dumped its env");
+        for name in dump
+            .lines()
+            .filter_map(|line| line.split_once('='))
+            .map(|(n, _)| n)
+        {
+            assert!(
+                ALLOWED_ENV.contains(&name),
+                "{name} reached ai-jail (pid {pid})"
+            );
+        }
+        assert!(dump.contains("HOOK_SECRET=hook-secret-value"));
+    }
+}
+
+fn init(f: &JailFixture, cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let settings = std::fs::read_to_string(f.base.join("settings.json")).expect("settings");
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        release_channel::init(semver::Version::new(0, 0, 0), cx);
+        agent_sandbox::init(cx);
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&settings, cx)
+                .result()
+                .expect("user settings parse");
+        });
+    });
+}
+
+async fn start(f: &JailFixture, cx: &mut TestAppContext) -> Rc<dyn AgentConnection> {
+    let project = Project::test(RealFs::new(None, cx.executor()), [f.root.as_path()], cx).await;
+    cx.run_until_parked();
+    let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
+    let delegate = AgentServerDelegate::new(store, None, None);
+    let server = CustomAgentServer::new(AgentId::new(AGENT));
+    let connect = cx.update(|cx| server.connect(delegate, project.clone(), cx));
+    let timeout = cx.background_executor.timer(Duration::from_secs(10));
+    let result = futures::select! {
+        result = futures::FutureExt::fuse(connect) => result,
+        _ = futures::FutureExt::fuse(timeout) => panic!("timed out starting the agent"),
+    };
+    cx.run_until_parked();
+    result.expect("the jailed start connects")
+}
+
+fn has_record(f: &JailFixture, cx: &mut TestAppContext) -> bool {
+    cx.update(|cx| LaunchRegistry::get(&AgentId::new(AGENT), &f.root, cx).is_some())
+}
+
+/// Gone or a zombie: a killed child is not always reaped at once, and a zombie runs nothing.
+fn process_is_gone(pid: &str) -> bool {
+    match std::fs::read_to_string(Path::new("/proc").join(pid).join("stat")) {
+        Err(_) => true,
+        Ok(stat) => stat
+            .rsplit_once(')')
+            .is_some_and(|(_, rest)| rest.trim_start().starts_with('Z')),
+    }
+}
+
+async fn wait_until(
+    cx: &mut TestAppContext,
+    what: &str,
+    mut done: impl FnMut(&mut TestAppContext) -> bool,
+) {
+    for _ in 0..50 {
+        cx.run_until_parked();
+        if done(cx) {
+            return;
+        }
+        cx.background_executor
+            .timer(Duration::from_millis(100))
+            .await;
+    }
+    panic!("{what}");
+}
+
+#[gpui::test]
+async fn jailed_v2_start_spawns_once_in_the_root_with_the_cleared_env(cx: &mut TestAppContext) {
+    let f = JailFixture::new(V2_RESULT);
+    init(&f, cx);
+    let connection = start(&f, cx).await;
+    assert!(
+        connection.clone().downcast::<AcpV2Connection>().is_some(),
+        "the agent speaks v2, so the v2 connection is kept"
+    );
+
+    let spawns = f.spawns();
+    assert_eq!(spawns.len(), 1, "exactly one spawn: {spawns:?}");
+    let (pid, cwd) = &spawns[0];
+    assert_eq!(
+        cwd,
+        &f.root.display().to_string(),
+        "spawned in the launch root"
+    );
+    f.assert_cleared_env(pid);
+    assert!(
+        has_record(&f, cx),
+        "registered while the v2 connection lives"
+    );
+
+    drop(connection);
+    wait_until(cx, "the launch record outlived its v2 connection", |cx| {
+        !has_record(&f, cx)
+    })
+    .await;
+    wait_until(cx, "the v2 process outlived its connection", |_| {
+        process_is_gone(pid)
+    })
+    .await;
+}
+
+#[gpui::test]
+async fn jailed_v1_fallback_replaces_the_v2_attempt_and_keeps_the_record(cx: &mut TestAppContext) {
+    // A v1-only agent (the Plus adapter today) answers a v2 `initialize` with protocol 1.
+    let f = JailFixture::new(V1_RESULT);
+    init(&f, cx);
+    let connection = start(&f, cx).await;
+    assert!(
+        connection.clone().downcast::<AcpConnection>().is_some(),
+        "the v1 fallback is kept"
+    );
+
+    let spawns = f.spawns();
+    assert_eq!(
+        spawns.len(),
+        2,
+        "the v2 attempt, then the v1 retry: {spawns:?}"
+    );
+    let ((first, first_cwd), (second, second_cwd)) = (&spawns[0], &spawns[1]);
+    assert_ne!(first, second, "the fallback is a fresh process");
+    for (pid, cwd) in [(first, first_cwd), (second, second_cwd)] {
+        assert_eq!(
+            cwd,
+            &f.root.display().to_string(),
+            "pid {pid} in the launch root"
+        );
+        f.assert_cleared_env(pid);
+    }
+    wait_until(cx, "the refused v2 attempt is still running", |_| {
+        process_is_gone(first)
+    })
+    .await;
+    assert!(!process_is_gone(second), "the kept v1 process runs");
+    assert!(
+        has_record(&f, cx),
+        "the record follows the live v1 process, not the refused attempt"
+    );
+
+    drop(connection);
+    wait_until(cx, "the launch record outlived its v1 connection", |cx| {
+        !has_record(&f, cx)
+    })
+    .await;
+    wait_until(cx, "the v1 process outlived its connection", |_| {
+        process_is_gone(second)
+    })
+    .await;
+}
diff --git a/crates/agent_servers/src/test_log_capture.rs b/crates/agent_servers/src/test_log_capture.rs
index 4fb94c7..024a465 100644
--- a/crates/agent_servers/src/test_log_capture.rs
+++ b/crates/agent_servers/src/test_log_capture.rs
@@ -4,42 +4,86 @@
 //! Materialize with `#[cfg(test)] mod test_log_capture;` in `agent_servers.rs`.
 //! The `log` crate accepts one logger per process; nothing else in this crate's
 //! test binary installs one (the e2e helpers that call `env_logger::try_init`
-//! are `#[ignore]`d without the `e2e` feature).
+//! are `#[ignore]`d without the `e2e` feature). A test that needs its own view
+//! of the log (story 043's launch line) registers a forwarding target with
+//! [`install_forwarding`] instead of installing a second logger.
 
 use std::sync::{Mutex, Once};
 
 struct Capture;
 
 static RECORDS: Mutex<Vec<(log::Level, String)>> = Mutex::new(Vec::new());
+static FORWARDS: Mutex<Vec<&'static dyn log::Log>> = Mutex::new(Vec::new());
+/// Serialises every change of the process-wide level, which is only ever raised: tests run in
+/// parallel, and a test lowering it would silence another test's capture.
+static LEVEL: Mutex<()> = Mutex::new(());
 static CAPTURE: Capture = Capture;
 
 impl log::Log for Capture {
     fn enabled(&self, metadata: &log::Metadata) -> bool {
-        metadata.level() <= log::Level::Warn
+        metadata.level() <= log::max_level()
     }
 
     fn log(&self, record: &log::Record) {
-        if self.enabled(record.metadata()) {
+        if record.level() <= log::Level::Warn {
             RECORDS
                 .lock()
                 .unwrap_or_else(|poisoned| poisoned.into_inner())
                 .push((record.level(), record.args().to_string()));
         }
+        let forwards = FORWARDS
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner())
+            .clone();
+        for forward in forwards {
+            forward.log(record);
+        }
     }
 
     fn flush(&self) {}
 }
 
-/// Installs the capturing logger once for the whole test process.
-pub(crate) fn install() {
+fn install_logger() {
     static ONCE: Once = Once::new();
     ONCE.call_once(|| {
         log::set_logger(&CAPTURE)
             .expect("another logger was installed before the thread-config log capture");
-        log::set_max_level(log::LevelFilter::Warn);
     });
 }
 
+fn raise_max_level(level: log::LevelFilter) {
+    let _guard = LEVEL
+        .lock()
+        .unwrap_or_else(|poisoned| poisoned.into_inner());
+    if log::max_level() < level {
+        log::set_max_level(level);
+    }
+}
+
+/// Installs the capturing logger once for the whole test process.
+pub(crate) fn install() {
+    install_logger();
+    raise_max_level(log::LevelFilter::Warn);
+}
+
+/// Installs the capturing logger and forwards every record, at every level, to `logger` too.
+/// Registering the same logger twice forwards once.
+pub(crate) fn install_forwarding(logger: &'static dyn log::Log) {
+    install_logger();
+    {
+        let mut forwards = FORWARDS
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner());
+        if !forwards
+            .iter()
+            .any(|known| std::ptr::addr_eq(*known as *const dyn log::Log, logger))
+        {
+            forwards.push(logger);
+        }
+    }
+    raise_max_level(log::LevelFilter::Trace);
+}
+
 /// True when one WARN-or-ERROR line contains every needle. Tests run in
 /// parallel in one process, so callers pass needles unique to their test.
 pub(crate) fn logged(needles: &[&str]) -> bool {
diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml
index 4e9c635..b3c9b39 100644
--- a/crates/agent_ui/Cargo.toml
+++ b/crates/agent_ui/Cargo.toml
@@ -31,6 +31,7 @@ action_log.workspace = true
 agent-client-protocol.workspace = true
 agent.workspace = true
 async-channel.workspace = true
+agent_sandbox.workspace = true
 agent_servers.workspace = true
 agent_settings.workspace = true
 agent_skills.workspace = true
diff --git a/crates/agent_ui/src/agent_jail_status.rs b/crates/agent_ui/src/agent_jail_status.rs
new file mode 100644
index 0000000..2a1819e
--- /dev/null
+++ b/crates/agent_ui/src/agent_jail_status.rs
@@ -0,0 +1,140 @@
+//! The agent thread's jail icon (story 043, R5): how an external agent was started, read from
+//! the `LaunchRegistry` record of its (agent, root), drawn where Zed draws its native sandbox
+//! icon.
+
+use agent_sandbox::launch::{self, LaunchRecord, MemoryPort, Reason};
+use agent_sandbox::probe;
+use gpui::{AnyElement, SharedString};
+use ui::{Divider, Tooltip, prelude::*};
+
+const REMOTE: &str = "The agent sandbox is not available for remote projects.";
+const OWN_COMMAND: &str = "Sandboxed by its agent_servers command; not managed by Zeo.";
+const NOT_CLAUDE: &str =
+    "Not a Claude agent: the agent sandbox applies only to the Claude adapters.";
+const DRIFTED: &str = "Restart the agent to apply the new setting.";
+
+/// What the icon draws and what its tooltip says.
+#[derive(Clone, Debug, PartialEq)]
+pub struct JailStatusSummary {
+    pub icon: IconName,
+    pub color: Color,
+    pub lines: Vec<SharedString>,
+}
+
+/// The icon's presentation for `record`; `drifted` is whether the settings of now differ from
+/// those the agent was started with (R5.4).
+pub fn jail_status_summary(record: &LaunchRecord, drifted: bool) -> JailStatusSummary {
+    let mut lines: Vec<SharedString> = Vec::new();
+    let icon = match record.reason {
+        Reason::On => {
+            lines.push("Started inside the agent sandbox (ai-jail).".into());
+            lines.push(
+                format!(
+                    "ai-jail version: {}",
+                    record
+                        .ai_jail_version
+                        .as_ref()
+                        .map_or_else(|| "unknown".to_string(), ToString::to_string)
+                )
+                .into(),
+            );
+            lines.push(list_line(
+                "Writable folders",
+                record.rw_maps.iter().map(|path| path.display().to_string()),
+            ));
+            lines.push(list_line(
+                "Allowed hosts",
+                record.allow_hosts.iter().cloned(),
+            ));
+            lines.push(list_line(
+                "Forwarded ports",
+                record.forward_ports.iter().map(ToString::to_string),
+            ));
+            IconName::Lock
+        }
+        Reason::OwnCommand => {
+            lines.push(OWN_COMMAND.into());
+            IconName::Lock
+        }
+        Reason::Off => {
+            lines.push("Started without the agent sandbox.".into());
+            IconName::LockOff
+        }
+        Reason::Remote => {
+            lines.push(REMOTE.into());
+            IconName::LockOff
+        }
+        Reason::NotClaude => {
+            lines.push(NOT_CLAUDE.into());
+            IconName::LockOff
+        }
+    };
+
+    let outdated_version = record
+        .ai_jail_version
+        .as_ref()
+        .filter(|version| icon == IconName::Lock && probe::is_outdated(version));
+    if let Some(version) = outdated_version {
+        lines.push(probe::outdated_warning(version).into());
+    }
+    if record.memory_port == MemoryPort::Unsupported {
+        lines.push(launch::MEMORY_PORT_UNSUPPORTED.into());
+    }
+    if drifted {
+        lines.push(DRIFTED.into());
+    }
+
+    let color = if outdated_version.is_some() {
+        Color::Warning
+    } else if icon == IconName::Lock {
+        Color::Default
+    } else {
+        Color::Muted
+    };
+    JailStatusSummary { icon, color, lines }
+}
+
+fn list_line(label: &str, items: impl Iterator<Item = String>) -> SharedString {
+    let items: Vec<String> = items.collect();
+    if items.is_empty() {
+        format!("{label}: none").into()
+    } else {
+        format!("{label}: {}", items.join(", ")).into()
+    }
+}
+
+/// The toolbar button for `summary`: the lock, its tooltip and a divider, like
+/// `ThreadView::render_sandbox_status`. A click opens the Agent Sandbox settings page (R5.5).
+pub fn render_jail_status_button(summary: JailStatusSummary) -> AnyElement {
+    let JailStatusSummary { icon, color, lines } = summary;
+    h_flex()
+        .gap_1()
+        .child(
+            IconButton::new("agent-jail-status", icon)
+                .icon_size(IconSize::Small)
+                .icon_color(color)
+                .tooltip(Tooltip::element(move |_window, _cx| {
+                    v_flex()
+                        .w(rems_from_px(320_f32))
+                        .gap_1()
+                        .child(Label::new("Agent Sandbox"))
+                        .children(lines.iter().map(|line| {
+                            Label::new(line.clone())
+                                .size(LabelSize::Small)
+                                .color(Color::Muted)
+                        }))
+                        .into_any_element()
+                }))
+                .on_click(|_, window, cx| {
+                    window.dispatch_action(
+                        Box::new(zed_actions::OpenSettingsAt {
+                            path: zed_actions::AGENT_JAIL_SETTINGS_PATH.to_string(),
+                            target: None,
+                        }),
+                        cx,
+                    );
+                }),
+        )
+        .child(Divider::vertical())
+        .into_any_element()
+}
diff --git a/crates/agent_ui/src/agent_jail_status_tests.rs b/crates/agent_ui/src/agent_jail_status_tests.rs
new file mode 100644
index 0000000..1a9c116
--- /dev/null
+++ b/crates/agent_ui/src/agent_jail_status_tests.rs
@@ -0,0 +1,559 @@
+// Story 043, tasks 4.1 and 8.1 — the thread's jail icon (R1.7, R1.8, R5.1–R5.7) and the palette
+// actions (R8.1–R8.4). Wired in the crate root `src/agent_ui.rs` as
+//
+//     #[cfg(test)]
+//     mod agent_jail_status_tests;
+//
+// Test paths are `agent_jail_status_tests::icon::…` and
+// `agent_jail_status_tests::agent_sandbox_actions::…`, so the Validation filters
+// `agent_jail_status` (4.1) and `agent_sandbox_actions` (8.1) select them.
+//
+// Contract (design.md §Toolbar icon, §Palette actions, §launch). Assumed signatures — adjust the
+// `use`/call only:
+//   - `agent_sandbox::launch::{LaunchRecord, LaunchRegistry, Reason}`; `LaunchRegistry::register(record, cx)`
+//   - `ThreadView::render_agent_jail_status(&mut self, cx) -> Option<AnyElement>`
+//   - the icon's presentation is computed by a pure seam the render calls:
+//     `crate::agent_jail_status::jail_status_summary(record: &LaunchRecord, drifted: bool)
+//      -> JailStatusSummary { icon: IconName, color: Color, lines: Vec<SharedString> }`
+//     (the tooltip shows `lines`; the IconButton uses `icon`/`color`). GPUI exposes no text query
+//     on a rendered tooltip, so this seam is where R5.2–R5.6 are asserted; the rendered panel is
+//     asserted through `IconButton`'s automatic `ICON-<name>` debug selector.
+//   - `zed_actions::AGENT_JAIL_SETTINGS_PATH == "agent_sandbox"`
+//   - `agent_sandbox::{Toggle}` / `agent_memory::{Status, OpenProjectMemory}` action types, at
+//     `crate::agent_sandbox_actions::{Toggle, Status, OpenProjectMemory}` or wherever declared.
+//
+// Hostile halves first:
+//   - icon key, wrongly COLLAPSE: a record for `/w/app2` draws nothing on a thread of `/w/app`;
+//     no record (agent still loading) draws nothing.
+//   - state, wrongly CLOSED: Off and Remote draw the open lock; wrongly OPEN: OwnCommand draws
+//     the closed lock; outdated draws the warning colour, never the default.
+//   - native thread, wrongly DOUBLE: a native thread never gets the jail icon.
+//   - toggle, wrongly SILENT-OFF: the palette's off path asks the same confirmation and Cancel
+//     writes nothing; wrongly ACT: with no local project nothing changes.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use acp_thread::StubAgentConnection;
+use agent_sandbox::launch::{LaunchRecord, LaunchRegistry, MemoryPort, Reason};
+use fs::{FakeFs, Fs};
+use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
+use parking_lot::Mutex;
+use project::Project;
+use project::agent_server_store::AgentId;
+use serde_json::json;
+use ui::{Color, IconName};
+use workspace::{MultiWorkspace, Workspace};
+
+use crate::AgentPanel;
+use crate::agent_jail_status::jail_status_summary;
+
+fn init(cx: &mut TestAppContext) -> Arc<FakeFs> {
+    crate::conversation_view::tests::init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    cx.update(|cx| {
+        agent::ThreadStore::init_global(cx);
+        language_model::LanguageModelRegistry::test(cx);
+        <dyn Fs>::set_global(fs.clone(), cx);
+        agent_sandbox::init(cx);
+    });
+    fs
+}
+
+fn record(root: &str, reason: Reason, version: Option<&str>) -> LaunchRecord {
+    LaunchRecord {
+        agent_id: AgentId::new("Test"),
+        project_root: PathBuf::from(root),
+        reason,
+        ai_jail_version: version.map(|v| semver::Version::parse(v).expect("version")),
+        ai_jail_path: None,
+        rw_maps: vec![
+            PathBuf::from("/h/.claude-jailed"),
+            PathBuf::from("/data/cache"),
+        ],
+        allow_hosts: vec!["anthropic.com".into(), "registry.npmjs.org".into()],
+        forward_ports: vec![49374],
+        memory_port: MemoryPort::Off,
+        settings_fingerprint: 0,
+        memory_fingerprint: 0,
+        settings_key: None,
+    }
+}
+
+struct Win {
+    workspace: Entity<Workspace>,
+    panel: Entity<AgentPanel>,
+    cx: VisualTestContext,
+}
+
+async fn window_with_stub_thread(fs: &Arc<FakeFs>, roots: &[&str], cx: &mut TestAppContext) -> Win {
+    for root in roots {
+        fs.insert_tree(root, json!({ "file.txt": "" })).await;
+    }
+    let paths: Vec<&Path> = roots.iter().map(|root| Path::new(*root)).collect();
+    let project = Project::test(fs.clone(), paths, cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+        .expect("workspace");
+    workspace.update(cx, |workspace, _| workspace.set_random_database_id());
+    let mut vcx = VisualTestContext::from_window(window.into(), cx);
+    // The toolbar is only drawn inside an open dock of a window with a size.
+    vcx.simulate_resize(gpui::size(gpui::px(1200.), gpui::px(800.)));
+    let panel = workspace.update_in(&mut vcx, |workspace, window, cx| {
+        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
+        workspace.add_panel(panel.clone(), window, cx);
+        workspace.focus_panel::<AgentPanel>(window, cx);
+        panel
+    });
+    vcx.run_until_parked();
+    crate::test_support::open_thread_with_connection(&panel, StubAgentConnection::new(), &mut vcx);
+    Win {
+        workspace,
+        panel,
+        cx: vcx,
+    }
+}
+
+fn has(summary_lines: &[ui::SharedString], needle: &str) -> bool {
+    summary_lines.iter().any(|line| line.contains(needle))
+}
+
+mod icon {
+    use super::*;
+
+    // --- presentation (R5.1–R5.6, R1.8) -----------------------------------------------------
+
+    #[test]
+    fn agent_jail_status_open_lock_for_off_and_remote() {
+        let off = jail_status_summary(&record("/w/app", Reason::Off, None), false);
+        assert_eq!(off.icon, IconName::LockOff);
+        assert_eq!(off.color, Color::Muted);
+        let remote = jail_status_summary(&record("/w/app", Reason::Remote, None), false);
+        assert_eq!(
+            remote.icon,
+            IconName::LockOff,
+            "remote is never shown as jailed"
+        );
+        assert!(
+            has(
+                &remote.lines,
+                "The agent sandbox is not available for remote projects."
+            ),
+            "{:?}",
+            remote.lines
+        );
+    }
+
+    #[test]
+    fn agent_jail_status_own_command_is_closed_and_says_so() {
+        let own = jail_status_summary(&record("/w/app", Reason::OwnCommand, None), false);
+        assert_eq!(own.icon, IconName::Lock);
+        assert!(
+            has(
+                &own.lines,
+                "Sandboxed by its agent_servers command; not managed by Zeo."
+            ),
+            "{:?}",
+            own.lines
+        );
+    }
+
+    #[test]
+    fn agent_jail_status_outdated_uses_the_warning_colour() {
+        let old = jail_status_summary(&record("/w/app", Reason::On, Some("2.3.9")), false);
+        assert_eq!(old.icon, IconName::Lock, "still jailed (A4)");
+        assert_eq!(old.color, Color::Warning);
+        for needle in ["GHSA-frgp-q3qc-g78p", "GHSA-w976-gw52-hvx2", "2.4.0"] {
+            assert!(has(&old.lines, needle), "{needle} in {:?}", old.lines);
+        }
+    }
+
+    #[test]
+    fn agent_jail_status_on_lists_what_the_agent_started_with() {
+        let on = jail_status_summary(&record("/w/app", Reason::On, Some("2.8.1")), false);
+        assert_eq!(on.icon, IconName::Lock);
+        assert_eq!(on.color, Color::Default);
+        for needle in [
+            "2.8.1",
+            "/h/.claude-jailed",
+            "/data/cache",
+            "anthropic.com",
+            "registry.npmjs.org",
+            "49374",
+        ] {
+            assert!(has(&on.lines, needle), "{needle} in {:?}", on.lines);
+        }
+        assert!(
+            !has(&on.lines, "GHSA-"),
+            "no advisory line for a safe version"
+        );
+        assert!(
+            !has(&on.lines, "Restart the agent"),
+            "no drift line without drift"
+        );
+    }
+
+    #[test]
+    fn agent_jail_status_not_claude_is_open_and_says_so() {
+        let other = jail_status_summary(&record("/w/app", Reason::NotClaude, None), false);
+        assert_eq!(
+            other.icon,
+            IconName::LockOff,
+            "a non-Claude agent is never shown as jailed"
+        );
+        assert_eq!(other.color, Color::Muted);
+        assert!(
+            has(
+                &other.lines,
+                "Not a Claude agent: the agent sandbox applies only to the Claude adapters."
+            ),
+            "{:?}",
+            other.lines
+        );
+    }
+
+    #[test]
+    fn agent_jail_status_memory_port_unsupported_adds_the_r3_10_line() {
+        let sentence =
+            "ai-memory is not reachable from inside the jail: this ai-jail has no --forward-port";
+        let unsupported = jail_status_summary(
+            &LaunchRecord {
+                memory_port: MemoryPort::Unsupported,
+                ..record("/w/app", Reason::On, Some("2.3.9"))
+            },
+            false,
+        );
+        assert!(has(&unsupported.lines, sentence), "{:?}", unsupported.lines);
+        let forwarded = jail_status_summary(
+            &LaunchRecord {
+                memory_port: MemoryPort::Forwarded(49374),
+                ..record("/w/app", Reason::On, Some("2.8.1"))
+            },
+            false,
+        );
+        assert!(
+            !has(&forwarded.lines, sentence),
+            "no R3.10 line when the port is forwarded: {:?}",
+            forwarded.lines
+        );
+    }
+
+    #[test]
+    fn agent_jail_status_drift_adds_the_restart_line() {
+        let drifted = jail_status_summary(&record("/w/app", Reason::On, Some("2.8.1")), true);
+        assert!(
+            has(
+                &drifted.lines,
+                "Restart the agent to apply the new setting."
+            ),
+            "{:?}",
+            drifted.lines
+        );
+    }
+
+    // --- rendered in the panel (R5.1, R5.5, R5.7) ------------------------------------------
+
+    #[gpui::test]
+    async fn agent_jail_status_record_of_another_root_draws_nothing(cx: &mut TestAppContext) {
+        let fs = init(cx);
+        cx.update(|cx| LaunchRegistry::register(record("/w/app2", Reason::On, Some("2.8.1")), cx));
+        let mut win = window_with_stub_thread(&fs, &["/w/app"], cx).await;
+        win.cx.run_until_parked();
+        assert!(
+            win.cx.debug_bounds("ICON-Lock").is_none(),
+            "another root's record is not this thread's"
+        );
+        assert!(win.cx.debug_bounds("ICON-LockOff").is_none());
+    }
+
+    #[gpui::test]
+    async fn agent_jail_status_closed_lock_opens_the_agent_sandbox_page(cx: &mut TestAppContext) {
+        let fs = init(cx);
+        cx.update(|cx| LaunchRegistry::register(record("/w/app", Reason::On, Some("2.8.1")), cx));
+        let opened: Arc<Mutex<Vec<String>>> = Arc::default();
+        cx.update({
+            let opened = opened.clone();
+            move |cx| {
+                cx.on_action(move |action: &zed_actions::OpenSettingsAt, _| {
+                    opened.lock().push(action.path.clone());
+                });
+            }
+        });
+        let mut win = window_with_stub_thread(&fs, &["/w/app"], cx).await;
+        win.cx.run_until_parked();
+        let lock = win
+            .cx
+            .debug_bounds("ICON-Lock")
+            .expect("the closed lock is drawn");
+        assert!(win.cx.debug_bounds("ICON-LockOff").is_none());
+        win.cx
+            .simulate_click(lock.center(), gpui::Modifiers::default());
+        win.cx.run_until_parked();
+        assert_eq!(
+            opened.lock().clone(),
+            vec![zed_actions::AGENT_JAIL_SETTINGS_PATH.to_string()]
+        );
+        assert_eq!(zed_actions::AGENT_JAIL_SETTINGS_PATH, "agent_sandbox");
+    }
+
+    #[gpui::test]
+    async fn agent_jail_status_off_draws_the_open_lock(cx: &mut TestAppContext) {
+        let fs = init(cx);
+        cx.update(|cx| LaunchRegistry::register(record("/w/app", Reason::Off, None), cx));
+        let mut win = window_with_stub_thread(&fs, &["/w/app"], cx).await;
+        win.cx.run_until_parked();
+        assert!(win.cx.debug_bounds("ICON-LockOff").is_some());
+        assert!(win.cx.debug_bounds("ICON-Lock").is_none());
+    }
+
+    #[gpui::test]
+    async fn agent_jail_status_never_on_a_native_thread(cx: &mut TestAppContext) {
+        let fs = init(cx);
+        // A record for the native agent in this root: only the native-thread guard can hide it.
+        cx.update(|cx| {
+            LaunchRegistry::register(
+                LaunchRecord {
+                    agent_id: agent::ZED_AGENT_ID.clone(),
+                    ..record("/w/app", Reason::On, Some("2.8.1"))
+                },
+                cx,
+            )
+        });
+        let mut win = window_with_stub_thread(&fs, &["/w/app"], cx).await;
+        // Open a native-agent thread the way agent_panel's own tests do; adjust the call only.
+        win.panel.update_in(&mut win.cx, |panel, window, cx| {
+            panel.new_external_agent_thread(
+                &crate::NewExternalAgentThread {
+                    agent: agent::ZED_AGENT_ID.clone(),
+                },
+                window,
+                cx,
+            )
+        });
+        win.cx.run_until_parked();
+        let thread_view = win
+            .panel
+            .read_with(&win.cx, |panel, cx| panel.active_thread_view(cx))
+            .expect("a native thread view");
+        assert!(
+            thread_view.update(&mut win.cx, |view, cx| view
+                .render_agent_jail_status(cx)
+                .is_none()),
+            "Zed's own sandbox icon stays alone on native threads (R5.7)"
+        );
+    }
+}
+
+mod agent_sandbox_actions {
+    use super::*;
+    use crate::agent_sandbox_actions::{OpenProjectMemory, Toggle};
+    use gpui::UpdateGlobal as _;
+    use settings::SettingsStore;
+
+    async fn workspace_window(fs: &Arc<FakeFs>, roots: &[&str], cx: &mut TestAppContext) -> Win {
+        window_with_stub_thread(fs, roots, cx).await
+    }
+
+    async fn user_settings(fs: &Arc<FakeFs>, value: serde_json::Value, cx: &mut TestAppContext) {
+        let text = value.to_string();
+        // The fake file system needs the parent before the file (as in confirm_tests).
+        fs.create_dir(paths::config_dir())
+            .await
+            .expect("the config dir");
+        fs.insert_file(paths::settings_file(), text.clone().into_bytes())
+            .await;
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store
+                    .set_user_settings(&text, cx)
+                    .result()
+                    .expect("settings");
+            })
+        });
+    }
+
+    #[gpui::test]
+    async fn agent_sandbox_actions_toggle_without_a_local_project_changes_nothing(
+        cx: &mut TestAppContext,
+    ) {
+        let fs = init(cx);
+        user_settings(&fs, json!({ "agent_sandbox": { "enabled": true } }), cx).await;
+        let before = fs.load(paths::settings_file()).await.expect("file");
+        let mut win = workspace_window(&fs, &[], cx).await;
+        let notifications_before = win
+            .workspace
+            .read_with(&win.cx, |w, _| w.notification_ids().len());
+        win.cx.dispatch_action(Toggle);
+        win.cx.run_until_parked();
+        assert!(
+            !win.cx.has_pending_prompt(),
+            "nothing to confirm without a project"
+        );
+        assert_eq!(fs.load(paths::settings_file()).await.expect("file"), before);
+        assert_eq!(
+            win.workspace
+                .read_with(&win.cx, |w, _| w.notification_ids().len()),
+            notifications_before + 1,
+            "says \"Open a local project to configure the agent sandbox.\""
+        );
+    }
+
+    #[gpui::test]
+    async fn agent_sandbox_actions_toggle_off_asks_and_cancel_writes_nothing(
+        cx: &mut TestAppContext,
+    ) {
+        let fs = init(cx);
+        user_settings(
+            &fs,
+            json!({ "agent_sandbox": { "projects": { "/w/app": { "enabled": true } } } }),
+            cx,
+        )
+        .await;
+        let before = fs.load(paths::settings_file()).await.expect("file");
+        let mut win = workspace_window(&fs, &["/w/app"], cx).await;
+        win.cx.dispatch_action(Toggle);
+        win.cx.run_until_parked();
+        let (message, _) = win.cx.pending_prompt().expect("the R4.1 confirmation");
+        assert!(
+            message.starts_with("Turn off the agent sandbox for "),
+            "{message}"
+        );
+        win.cx.simulate_prompt_answer("Cancel");
+        win.cx.run_until_parked();
+        assert_eq!(fs.load(paths::settings_file()).await.expect("file"), before);
+    }
+
+    #[gpui::test]
+    async fn agent_sandbox_actions_toggle_on_saves_without_asking(cx: &mut TestAppContext) {
+        let fs = init(cx);
+        user_settings(&fs, json!({ "agent_sandbox": { "enabled": false } }), cx).await;
+        let mut win = workspace_window(&fs, &["/w/app"], cx).await;
+        win.cx.dispatch_action(Toggle);
+        win.cx.run_until_parked();
+        assert!(!win.cx.has_pending_prompt());
+        let written: serde_json::Value =
+            serde_json_lenient::from_str(&fs.load(paths::settings_file()).await.expect("file"))
+                .expect("json");
+        assert_eq!(
+            written["agent_sandbox"]["projects"]["/w/app"]["enabled"],
+            json!(true)
+        );
+    }
+
+    #[gpui::test]
+    async fn agent_sandbox_actions_open_project_memory_opens_the_memory_section(
+        cx: &mut TestAppContext,
+    ) {
+        let fs = init(cx);
+        let opened: Arc<Mutex<Vec<String>>> = Arc::default();
+        cx.update({
+            let opened = opened.clone();
+            move |cx| {
+                cx.on_action(move |action: &zed_actions::OpenSettingsAt, _| {
+                    opened.lock().push(action.path.clone());
+                });
+            }
+        });
+        let mut win = workspace_window(&fs, &["/w/app"], cx).await;
+        win.cx.dispatch_action(OpenProjectMemory);
+        win.cx.run_until_parked();
+        assert_eq!(opened.lock().clone(), vec!["agent_memory".to_string()]);
+    }
+
+    // --- authored at Run (8.1), Red first -----------------------------------------------------
+
+    // Status, wrongly CALL: with memory off nothing reaches ai-memory, and the user is told so.
+    #[gpui::test]
+    async fn agent_sandbox_actions_status_with_memory_off_makes_no_call(cx: &mut TestAppContext) {
+        use crate::agent_sandbox_actions::{MEMORY_OFF, MemoryStatusToast, Status};
+        use std::sync::atomic::{AtomicUsize, Ordering};
+
+        let fs = init(cx);
+        let requests = Arc::new(AtomicUsize::new(0));
+        cx.update({
+            let requests = requests.clone();
+            move |cx| {
+                cx.set_http_client(http_client::FakeHttpClient::create(move |_| {
+                    requests.fetch_add(1, Ordering::SeqCst);
+                    async { anyhow::bail!("no request is expected while memory is off") }
+                }));
+            }
+        });
+        user_settings(&fs, json!({ "agent_memory": { "enabled": false } }), cx).await;
+        let mut win = workspace_window(&fs, &["/w/app"], cx).await;
+        let notifications_before = win
+            .workspace
+            .read_with(&win.cx, |w, _| w.notification_ids().len());
+        win.cx.dispatch_action(Status);
+        win.cx.run_until_parked();
+        assert_eq!(
+            requests.load(Ordering::SeqCst),
+            0,
+            "memory off calls nothing"
+        );
+        let ids = win
+            .workspace
+            .read_with(&win.cx, |w, _| w.notification_ids());
+        assert_eq!(ids.len(), notifications_before + 1);
+        assert!(
+            ids.contains(&workspace::notifications::NotificationId::unique::<
+                MemoryStatusToast,
+            >()),
+            "the status notification is the one shown"
+        );
+        assert_eq!(MEMORY_OFF, "Memory is off.");
+    }
+
+    // Toggle, wrongly SPLIT: a project opened through a symlink whose key is the canonical
+    // path reads and writes THAT key — it asks before turning off, and adds no second key.
+    #[gpui::test]
+    async fn agent_sandbox_actions_toggle_through_a_symlink_uses_the_canonical_key(
+        cx: &mut TestAppContext,
+    ) {
+        let tmp = tempfile::tempdir().expect("tempdir");
+        let base = tmp.path().canonicalize().expect("canonical");
+        let real = base.join("app");
+        let link = base.join("link");
+        std::fs::create_dir(&real).expect("project dir");
+        std::os::unix::fs::symlink(&real, &link).expect("symlink");
+        let real_key = real.to_str().expect("utf-8").to_string();
+        let link_root = link.to_str().expect("utf-8").to_string();
+
+        let fs = init(cx);
+        user_settings(
+            &fs,
+            json!({ "agent_sandbox": { "projects": {
+                real_key.clone(): { "enabled": true, "rw_maps": ["/data/cache"] }
+            } } }),
+            cx,
+        )
+        .await;
+        let mut win = workspace_window(&fs, &[link_root.as_str()], cx).await;
+        win.cx.dispatch_action(Toggle);
+        win.cx.run_until_parked();
+        let (message, _) = win
+            .cx
+            .pending_prompt()
+            .expect("the canonical key is on, so the toggle turns it off and asks");
+        assert!(
+            message.starts_with("Turn off the agent sandbox for "),
+            "{message}"
+        );
+        win.cx.simulate_prompt_answer("Turn Off");
+        win.cx.run_until_parked();
+        let written: serde_json::Value =
+            serde_json_lenient::from_str(&fs.load(paths::settings_file()).await.expect("file"))
+                .expect("json");
+        let projects = written["agent_sandbox"]["projects"]
+            .as_object()
+            .expect("projects");
+        assert_eq!(
+            projects.keys().cloned().collect::<Vec<_>>(),
+            vec![real_key.clone()],
+            "no second key for the symlink spelling"
+        );
+        assert_eq!(projects[&real_key]["enabled"], json!(false));
+        assert_eq!(projects[&real_key]["rw_maps"], json!(["/data/cache"]));
+    }
+}
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 1a333b0..83be94a 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -724,6 +724,7 @@ pub fn init(cx: &mut App) {
         },
     )
     .detach();
+    crate::agent_sandbox_actions::init(cx);
 }
 
 fn format_selection_for_terminal(
@@ -1154,6 +1155,8 @@ pub struct AgentPanel {
     _active_draft_reclaim_observation: Option<Subscription>,
     _thread_metadata_store_subscription: Subscription,
     _settings_subscription: Subscription,
+    /// Repaints the toolbar's jail icon when an agent starts or stops (story 043, R5.1).
+    _launch_registry_subscription: Subscription,
     retained_thread_subscriptions: HashMap<ThreadId, Subscription>,
     last_context_source: Option<AgentContextSource>,
     /// The floating list of the live tasks of every conversation this panel holds.
@@ -1543,6 +1546,8 @@ impl AgentPanel {
             // The tasks card's `enabled` and `position` are read when the panel renders.
             cx.notify();
         });
+        let _launch_registry_subscription =
+            cx.observe_global::<agent_sandbox::LaunchRegistry>(|_, cx| cx.notify());
 
         cx.on_release(|this, cx| {
             this.dismiss_all_terminal_notifications(cx);
@@ -1656,6 +1661,7 @@ impl AgentPanel {
             _active_draft_reclaim_observation: None,
             _thread_metadata_store_subscription,
             _settings_subscription,
+            _launch_registry_subscription,
             retained_thread_subscriptions: HashMap::default(),
             last_context_source: None,
             tasks_card,
@@ -7065,7 +7071,12 @@ impl AgentPanel {
                 .active_conversation_view()
                 .and_then(|conversation_view| conversation_view.read(cx).root_thread_view())
                 .and_then(|thread_view| {
-                    thread_view.update(cx, |thread_view, cx| thread_view.render_sandbox_status(cx))
+                    // Zed's icon on native threads, the agent sandbox's on external ones (R5.7).
+                    thread_view.update(cx, |thread_view, cx| {
+                        thread_view
+                            .render_sandbox_status(cx)
+                            .or_else(|| thread_view.render_agent_jail_status(cx))
+                    })
                 });
 
             base_container
diff --git a/crates/agent_ui/src/agent_sandbox_actions.rs b/crates/agent_ui/src/agent_sandbox_actions.rs
new file mode 100644
index 0000000..51b1d6b
--- /dev/null
+++ b/crates/agent_ui/src/agent_sandbox_actions.rs
@@ -0,0 +1,213 @@
+//! The palette commands of the agent sandbox and of ai-memory (R8.1–R8.4).
+
+use std::path::PathBuf;
+
+use agent_sandbox::memory::{self, MemoryClient, MemoryError, MemoryStatus, display_endpoint};
+use agent_sandbox::{
+    AgentMemorySettings, AgentSandboxSettings, confirm, launch, resolve_project_key,
+};
+use gpui::{App, AppContext as _, Context, TaskExt as _, Window, actions};
+use settings::Settings as _;
+use workspace::notifications::NotificationId;
+use workspace::{Toast, Workspace};
+
+actions!(
+    agent_sandbox,
+    [
+        /// Turns the agent sandbox on for the active project, or off after a confirmation.
+        Toggle,
+    ]
+);
+
+actions!(
+    agent_memory,
+    [
+        /// Shows whether ai-memory is reachable, and the active project's scope and page count.
+        Status,
+        /// Opens the ai-memory settings of the active project.
+        OpenProjectMemory,
+    ]
+);
+
+/// The id of the `agent sandbox: toggle` notification.
+pub(crate) struct SandboxToggleToast;
+/// The id of the `agent memory: status` notification; a new status replaces the last one.
+pub(crate) struct MemoryStatusToast;
+
+/// The R8.2 text.
+pub(crate) const NO_LOCAL_PROJECT: &str = "Open a local project to configure the agent sandbox.";
+pub(crate) const MEMORY_OFF: &str = "Memory is off.";
+const MEMORY_NO_PROJECT: &str = "Open a local project to see its memory.";
+const MEMORY_INVALID_ENDPOINT: &str =
+    "The ai-memory endpoint in agent_memory.endpoint is not a valid URL.";
+/// The settings sub-page "Agent Memory (ai-memory)" (7.1).
+const MEMORY_SETTINGS_PATH: &str = "agent_memory";
+
+pub(crate) fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        workspace
+            .register_action(toggle)
+            .register_action(status)
+            .register_action(|_workspace, _: &OpenProjectMemory, window, cx| {
+                window.dispatch_action(
+                    Box::new(zed_actions::OpenSettingsAt {
+                        path: MEMORY_SETTINGS_PATH.into(),
+                        target: None,
+                    }),
+                    cx,
+                );
+            });
+    })
+    .detach();
+}
+
+/// The root an agent of the active project starts in, unless the project is remote: neither the
+/// sandbox nor ai-memory's scope applies there.
+fn local_root(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
+    let project = workspace.project();
+    if !project.read(cx).is_local() {
+        return None;
+    }
+    launch::project_root(project, cx)
+}
+
+fn toggle(workspace: &mut Workspace, _: &Toggle, window: &mut Window, cx: &mut Context<Workspace>) {
+    let Some(root) = local_root(workspace, cx) else {
+        workspace.show_toast(
+            Toast::new(
+                NotificationId::unique::<SandboxToggleToast>(),
+                NO_LOCAL_PROJECT,
+            ),
+            cx,
+        );
+        return;
+    };
+    let sandbox = AgentSandboxSettings::get_global(cx).clone();
+    cx.spawn_in(window, async move |_workspace, cx| {
+        // The same key the settings page shows: lexical first, then through a symlink, which
+        // canonicalizes and so blocks.
+        let enabled = cx
+            .background_spawn({
+                let root = root.clone();
+                async move {
+                    let key = resolve_project_key(sandbox.project_keys(), &root);
+                    sandbox.policy_for_key(key).enabled
+                }
+            })
+            .await;
+        // `set_project_enabled` confirms before turning the jail off and reports its own errors.
+        let saved = cx
+            .update(|window, cx| confirm::set_project_enabled(&root, !enabled, window, cx))?
+            .await;
+        log::info!(
+            "agent_sandbox event=palette_toggle enabled={} saved={saved}",
+            !enabled
+        );
+        anyhow::Ok(())
+    })
+    .detach_and_log_err(cx);
+}
+
+fn status(workspace: &mut Workspace, _: &Status, window: &mut Window, cx: &mut Context<Workspace>) {
+    let memory_settings = AgentMemorySettings::get_global(cx).clone();
+    if !memory_settings.enabled {
+        show_memory_status(workspace, MEMORY_OFF.to_string(), cx);
+        return;
+    }
+    let Some(root) = local_root(workspace, cx) else {
+        show_memory_status(workspace, MEMORY_NO_PROJECT.to_string(), cx);
+        return;
+    };
+    let scope = memory::resolve_project_scope(&root, workspace.project(), cx);
+    let http_client = cx.http_client();
+    cx.spawn_in(window, async move |workspace, cx| {
+        let scope = scope.await;
+        let text = match url::Url::parse(&memory_settings.endpoint) {
+            Err(_) => MEMORY_INVALID_ENDPOINT.to_string(),
+            Ok(endpoint) => {
+                let auth_token = std::env::var("AI_MEMORY_AUTH_TOKEN").ok();
+                match MemoryClient::new(endpoint, auth_token, http_client, cx) {
+                    Err(error) => first_line(&error),
+                    Ok(client) => status_text(&client.status(&scope).await),
+                }
+            }
+        };
+        workspace.update(cx, |workspace, cx| show_memory_status(workspace, text, cx))
+    })
+    .detach_and_log_err(cx);
+}
+
+/// The `agent memory: status` text (R8.3): the reachability, the scope and its page count, or
+/// the R6.6/R6.8 message.
+pub(crate) fn status_text(result: &Result<MemoryStatus, MemoryError>) -> String {
+    match result {
+        Ok(status) => format!(
+            "ai-memory {} is reachable at {} · {}: {} pages",
+            status.server_version,
+            display_endpoint(&status.endpoint),
+            status.scope,
+            status.pages
+        ),
+        Err(error) => error.to_string(),
+    }
+}
+
+fn show_memory_status(workspace: &mut Workspace, text: String, cx: &mut Context<Workspace>) {
+    workspace.show_toast(
+        Toast::new(NotificationId::unique::<MemoryStatusToast>(), text),
+        cx,
+    );
+}
+
+fn first_line(error: &anyhow::Error) -> String {
+    format!("{error:#}")
+        .lines()
+        .next()
+        .unwrap_or_default()
+        .to_string()
+}
+
+#[cfg(test)]
+mod status_text_tests {
+    use super::*;
+    use agent_sandbox::memory::{MemoryScope, ResolvedBy};
+
+    fn scope() -> MemoryScope {
+        MemoryScope {
+            workspace: "zeo-workspace".into(),
+            project: "zed".into(),
+            resolved_by: ResolvedBy::Marker,
+        }
+    }
+
+    #[test]
+    fn agent_sandbox_actions_status_text_names_reachability_scope_and_pages() {
+        let status = MemoryStatus {
+            server_version: "2.6.3".into(),
+            endpoint: url::Url::parse("http://127.0.0.1:49374/mcp").expect("url"),
+            scope: scope(),
+            pages: 77,
+            sessions: 129,
+        };
+        assert_eq!(
+            status_text(&Ok(status)),
+            "ai-memory 2.6.3 is reachable at http://127.0.0.1:49374/mcp · zeo-workspace/zed: 77 pages"
+        );
+    }
+
+    #[test]
+    fn agent_sandbox_actions_status_text_shows_the_r6_messages() {
+        let unreachable = MemoryError::Unreachable {
+            endpoint: "http://127.0.0.1:49374/mcp".into(),
+        };
+        assert_eq!(
+            status_text(&Err(unreachable)),
+            "ai-memory is not reachable at http://127.0.0.1:49374/mcp"
+        );
+        let unknown = MemoryError::UnknownProject { scope: scope() };
+        assert_eq!(
+            status_text(&Err(unknown)),
+            "No memory recorded for zeo-workspace/zed yet."
+        );
+    }
+}
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index bff4628..2258589 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -1,9 +1,11 @@
 mod agent_configuration;
 pub mod agent_connection_store;
 mod agent_diff;
+pub mod agent_jail_status;
 mod agent_model_selector;
 mod agent_panel;
 mod agent_registry_ui;
+pub mod agent_sandbox_actions;
 mod agent_session_router;
 mod buffer_codegen;
 mod completion_provider;
@@ -1123,6 +1125,9 @@ mod slash_command_in_code_block_tests;
 #[cfg(test)]
 mod devtools_mention_tests;
 
+#[cfg(test)]
+mod agent_jail_status_tests;
+
 #[cfg(test)]
 mod devtools_delivery_tests;
 
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 680ef9c..4a27440 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -6759,6 +6759,22 @@ impl ThreadView {
         )
     }
 
+    /// The jail icon of an external agent's thread (R5), from the launch record of its
+    /// (agent, root). `None` on a native thread, which keeps Zed's own icon (R5.7), and while no
+    /// record exists yet (the agent is still starting).
+    pub fn render_agent_jail_status(&self, cx: &App) -> Option<AnyElement> {
+        if self.as_native_thread(cx).is_some() {
+            return None;
+        }
+        let project = self.project.upgrade()?;
+        let root = agent_sandbox::launch::project_root(&project, cx)?;
+        let record = agent_sandbox::LaunchRegistry::get(&self.agent_id, &root, cx)?;
+        let drifted = agent_sandbox::launch::drifted_now(&record, cx);
+        Some(crate::agent_jail_status::render_jail_status_button(
+            crate::agent_jail_status::jail_status_summary(&record, drifted),
+        ))
+    }
+
     fn render_fast_mode_control(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
         if !self.fast_mode_available(cx) {
             return None;
diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs
index 486b665..7e9a5db 100644
--- a/crates/project/src/agent_server_store.rs
+++ b/crates/project/src/agent_server_store.rs
@@ -142,6 +142,14 @@ pub trait ExternalAgentServer {
 
     fn set_loading_status_tx(&mut self, _tx: watch::Sender<Option<String>>) {}
 
+    /// The names of the environment variables this agent's own configuration sets: its
+    /// settings `env`, its distribution's, and what the launcher adds itself. Never the
+    /// project's or Zed's inherited environment, which `get_command` merges in too — a caller
+    /// that forwards variables into a sandbox forwards only these.
+    fn configured_env_names(&self) -> std::collections::HashSet<String> {
+        std::collections::HashSet::new()
+    }
+
     fn as_any(&self) -> &dyn Any;
     fn as_any_mut(&mut self) -> &mut dyn Any;
 }
@@ -1153,6 +1161,16 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent {
         self.loading_status_tx = Some(tx);
     }
 
+    /// The settings `env` and every platform's target `env`: which target applies is decided
+    /// inside `get_command`, and a name from another platform's target is harmless.
+    fn configured_env_names(&self) -> std::collections::HashSet<String> {
+        self.env
+            .keys()
+            .chain(self.targets.values().flat_map(|target| target.env.keys()))
+            .cloned()
+            .collect()
+    }
+
     fn get_command(
         &mut self,
         extra_args: Vec<String>,
@@ -1372,6 +1390,18 @@ impl ExternalAgentServer for LocalRegistryNpxAgent {
         self.new_version_available_tx = Some(tx);
     }
 
+    /// The distribution and settings `env`, plus what `node_runtime::npm_command_env` sets on
+    /// the npm path (`PATH`, `NODE_EXTRA_CA_CERTS`). Both launch sources answer the same: the
+    /// system binary (patch `0046`) is decided only at start.
+    fn configured_env_names(&self) -> std::collections::HashSet<String> {
+        self.distribution_env
+            .keys()
+            .chain(self.settings_env.keys())
+            .cloned()
+            .chain(["PATH".to_string(), "NODE_EXTRA_CA_CERTS".to_string()])
+            .collect()
+    }
+
     fn get_command(
         &mut self,
         extra_args: Vec<String>,
@@ -1613,6 +1643,16 @@ struct LocalCustomAgent {
 }
 
 impl ExternalAgentServer for LocalCustomAgent {
+    /// The settings `env` of the custom entry.
+    fn configured_env_names(&self) -> std::collections::HashSet<String> {
+        self.command
+            .env
+            .iter()
+            .flat_map(|env| env.keys())
+            .cloned()
+            .collect()
+    }
+
     fn get_command(
         &mut self,
         extra_args: Vec<String>,
@@ -2612,6 +2652,109 @@ mod tests {
         result
     }
 
+    fn names(names: &[&str]) -> std::collections::HashSet<String> {
+        names.iter().map(|name| name.to_string()).collect()
+    }
+
+    fn env_of(names: &[&str]) -> HashMap<String, String> {
+        names
+            .iter()
+            .map(|name| (name.to_string(), format!("value of {name}")))
+            .collect()
+    }
+
+    // Story 043 (R9.4, tech review H1): `get_command` starts from the project's login-shell
+    // environment, so a sandbox forwarding every key of `cmd.env` would forward every secret
+    // of the shell. Each agent therefore names only what its own configuration sets.
+    #[cfg(feature = "test-support")]
+    #[gpui::test]
+    async fn configured_env_names_are_the_agents_own(cx: &mut TestAppContext) {
+        let fs = fs::FakeFs::new(cx.executor());
+
+        let custom = cx.update(|cx| {
+            let fs: Arc<dyn Fs> = fs.clone();
+            let worktree_store =
+                cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
+            let project_environment = cx.new(|cx| {
+                crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
+            });
+            LocalCustomAgent {
+                project_environment,
+                command: AgentServerCommand {
+                    path: "/usr/bin/custom-agent".into(),
+                    args: Vec::new(),
+                    env: Some(env_of(&["CUSTOM_KEY"])),
+                },
+            }
+        });
+        assert_eq!(custom.configured_env_names(), names(&["CUSTOM_KEY"]));
+
+        let mut archive = make_registry_archive_agent(
+            cx,
+            PathBuf::from("/agents/archive"),
+            Arc::new(http_client::BlockedHttpClient::new()),
+            None,
+        );
+        archive.env = env_of(&["ARCHIVE_SETTINGS_KEY"]);
+        for (platform, target) in archive.targets.iter_mut() {
+            target.env = env_of(&[&format!("TARGET_{}", platform.replace('-', "_"))]);
+        }
+        let mut expected = names(&["ARCHIVE_SETTINGS_KEY"]);
+        expected.extend(
+            archive
+                .targets
+                .keys()
+                .map(|platform| format!("TARGET_{}", platform.replace('-', "_"))),
+        );
+        assert_eq!(
+            archive.configured_env_names(),
+            expected,
+            "settings env and every platform's target env"
+        );
+
+        let mut npx = make_zeo_npx_agent(
+            cx,
+            fs,
+            "claude-agent-acp-plus",
+            "@lucascouts/claude-agent-acp-plus",
+            |_| Ok(false),
+        );
+        npx.distribution_env = env_of(&["DISTRIBUTION_KEY"]);
+        npx.settings_env = env_of(&["NPX_SETTINGS_KEY"]);
+        assert_eq!(
+            npx.configured_env_names(),
+            names(&[
+                "DISTRIBUTION_KEY",
+                "NPX_SETTINGS_KEY",
+                "PATH",
+                "NODE_EXTRA_CA_CERTS"
+            ]),
+            "the npm launcher sets PATH and NODE_EXTRA_CA_CERTS itself"
+        );
+
+        struct Unconfigured;
+        impl ExternalAgentServer for Unconfigured {
+            fn get_command(
+                &mut self,
+                _: Vec<String>,
+                _: HashMap<String, String>,
+                _: &mut AsyncApp,
+            ) -> Task<Result<AgentServerCommand>> {
+                Task::ready(Err(anyhow::anyhow!("not started in this test")))
+            }
+            fn as_any(&self) -> &dyn Any {
+                self
+            }
+            fn as_any_mut(&mut self) -> &mut dyn Any {
+                self
+            }
+        }
+        assert!(
+            Unconfigured.configured_env_names().is_empty(),
+            "an agent that configures nothing forwards nothing"
+        );
+    }
+
     async fn clear_npx_dir(fs: &fs::FakeFs, registry_id: &str) {
         fs.remove_dir(
             &npx_install_dir(registry_id),
diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml
index 85685bb..bbe54f3 100644
--- a/crates/remote_server/Cargo.toml
+++ b/crates/remote_server/Cargo.toml
@@ -87,7 +87,7 @@ acp_thread.workspace = true
 action_log.workspace = true
 agent = { workspace = true, features = ["test-support"] }
 agent-client-protocol.workspace = true
-agent_servers.workspace = true
+agent_servers = { workspace = true, features = ["test-support"] }
 client = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs
index b8a6303..e3c2b07 100644
--- a/crates/settings/src/vscode_import.rs
+++ b/crates/settings/src/vscode_import.rs
@@ -248,6 +248,8 @@ impl VsCodeSettings {
             feature_flags: None,
             instrumentation: None,
             devtools_bridge: None,
+            agent_sandbox: None,
+            agent_memory: None,
         }
     }
 
diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs
index 48d1425..92a6c6b 100644
--- a/crates/settings_content/src/settings_content.rs
+++ b/crates/settings_content/src/settings_content.rs
@@ -100,6 +100,7 @@ macro_rules! settings_overrides {
 }
 use std::collections::{BTreeMap, BTreeSet};
 use std::hash::Hash;
+use std::path::PathBuf;
 use std::sync::Arc;
 pub use util::serde::default_true;
 
@@ -340,6 +341,75 @@ pub struct SettingsContent {
     /// Settings for the devtools bridge: the unix socket a dev-server page
     /// sends element selections to.
     pub devtools_bridge: Option<DevtoolsBridgeSettingsContent>,
+
+    /// Settings for running the Claude agent adapters inside ai-jail. Read from
+    /// the user settings file only: a project file cannot loosen its own jail.
+    pub agent_sandbox: Option<AgentSandboxSettingsContent>,
+
+    /// Settings for the ai-memory server the agents talk to. Read from the
+    /// user settings file only.
+    pub agent_memory: Option<AgentMemorySettingsContent>,
+}
+
+/// Configuration for running the Claude agent adapters inside ai-jail.
+///
+/// Only the user settings file feeds this section; the same key in a project
+/// settings file is ignored with a warning.
+#[with_fallible_options]
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct AgentSandboxSettingsContent {
+    /// Whether projects without an entry in `projects` run their agent jailed.
+    ///
+    /// Default: false
+    pub enabled: Option<bool>,
+    /// The ai-jail binary to run. `null` looks it up on `PATH`.
+    ///
+    /// Default: null
+    pub ai_jail_path: Option<PathBuf>,
+    /// The Claude config directory mapped into the jail, instead of `~/.claude`.
+    ///
+    /// Default: "~/.claude-jailed"
+    pub claude_config_dir: Option<String>,
+    /// Writable folders every jailed project gets, before its own.
+    ///
+    /// Default: []
+    pub rw_maps: Option<Vec<String>>,
+    /// Hosts every jailed project may reach, before its own.
+    ///
+    /// Default: ["anthropic.com", "platform.claude.com"]
+    pub allow_hosts: Option<Vec<String>>,
+    /// Per-project overrides, keyed by the project's absolute root path.
+    ///
+    /// Default: {}
+    pub projects: Option<BTreeMap<String, AgentSandboxProjectSettingsContent>>,
+}
+
+/// The sandbox settings of one project, keyed by its absolute root path.
+#[with_fallible_options]
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct AgentSandboxProjectSettingsContent {
+    /// Whether this project's agent runs jailed. Unset follows `agent_sandbox.enabled`.
+    pub enabled: Option<bool>,
+    /// Writable folders added after the defaults.
+    pub rw_maps: Option<Vec<String>>,
+    /// Allowed hosts added after the defaults.
+    pub allow_hosts: Option<Vec<String>>,
+}
+
+/// Configuration for the ai-memory server the agents talk to.
+///
+/// Only the user settings file feeds this section.
+#[with_fallible_options]
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
+pub struct AgentMemorySettingsContent {
+    /// Whether the agents use ai-memory.
+    ///
+    /// Default: true
+    pub enabled: Option<bool>,
+    /// The ai-memory MCP endpoint.
+    ///
+    /// Default: "http://127.0.0.1:49374/mcp"
+    pub endpoint: Option<String>,
 }
 
 /// Configuration for the devtools bridge, which listens on
@@ -430,7 +500,7 @@ fallible_options::flattened_deserialize!(SettingsContent {
         journal, log, line_indicator_format, language_models, copilot, outline_panel, project_panel,
         node, proxy, reduce_motion, server_url, credentials_url, session, telemetry, terminal,
         title_bar, vim_mode, calls, which_key, vim, modeline_lines, feature_flags,
-        instrumentation, devtools_bridge,
+        instrumentation, devtools_bridge, agent_sandbox, agent_memory,
     },
     defaults: {},
 });
diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml
index b0b2f7c..456a63c 100644
--- a/crates/settings_ui/Cargo.toml
+++ b/crates/settings_ui/Cargo.toml
@@ -18,6 +18,7 @@ test-support = []
 
 [dependencies]
 agent.workspace = true
+agent_sandbox.workspace = true
 agent_settings.workspace = true
 agent_skills.workspace = true
 anyhow.workspace = true
@@ -58,6 +59,7 @@ release_channel.workspace = true
 rodio.workspace = true
 schemars.workspace = true
 search.workspace = true
+semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 serde_yaml_ng.workspace = true
@@ -80,12 +82,16 @@ credentials_provider = { workspace = true, features = ["test-support"] }
 fs = { workspace = true, features = ["test-support"] }
 futures.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
+http_client = { workspace = true, features = ["test-support"] }
 language_models = { workspace = true, features = ["test-support"] }
+parking_lot.workspace = true
 paths.workspace = true
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true
+serde_json_lenient.workspace = true
 settings = { workspace = true, features = ["test-support"] }
+tempfile.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
 
 [package.metadata.cargo-shear]
diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs
index 3c96ecd..16d0078 100644
--- a/crates/settings_ui/src/page_data.rs
+++ b/crates/settings_ui/src/page_data.rs
@@ -13,9 +13,10 @@ use crate::{
     ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata,
     SettingsPage, SettingsPageItem, SubPageLink, USER, active_language, all_language_names,
     pages::{
-        open_audio_test_window, render_edit_prediction_setup_page, render_external_agents_page,
-        render_llm_providers_page, render_mcp_servers_page, render_sandbox_settings_page,
-        render_skills_setup_page, render_tool_permissions_setup_page,
+        open_audio_test_window, render_agent_jail_page, render_agent_memory_page,
+        render_edit_prediction_setup_page, render_external_agents_page, render_llm_providers_page,
+        render_mcp_servers_page, render_sandbox_settings_page, render_skills_setup_page,
+        render_tool_permissions_setup_page,
     },
 };
 
@@ -9293,6 +9294,40 @@ fn ai_page(cx: &App) -> SettingsPage {
                 files: USER,
                 render: render_sandbox_settings_page,
             }),
+            SettingsPageItem::SubPageLink(SubPageLink {
+                title: "Agent Sandbox (ai-jail)".into(),
+                r#type: Default::default(),
+                json_path: Some(zed_actions::AGENT_JAIL_SETTINGS_PATH),
+                description: Some(
+                    "Run the Claude agents of a project inside ai-jail: writable folders, allowed hosts and ai-memory."
+                        .into(),
+                ),
+                search_aliases: &[
+                    "ai-jail",
+                    "jail",
+                    "memory",
+                    "ai-memory",
+                    "forward-port",
+                    "rw-map",
+                    "allow-host",
+                ],
+                in_json: true,
+                files: USER,
+                render: render_agent_jail_page,
+            }),
+            SettingsPageItem::SubPageLink(SubPageLink {
+                title: "Agent Memory (ai-memory)".into(),
+                r#type: Default::default(),
+                json_path: Some("agent_memory"),
+                description: Some(
+                    "What ai-memory holds for the active project: status, recent pages and Forget."
+                        .into(),
+                ),
+                search_aliases: &["memory", "ai-memory", "forget"],
+                in_json: true,
+                files: USER,
+                render: render_agent_memory_page,
+            }),
             SettingsPageItem::SubPageLink(SubPageLink {
                 title: "Tool Permissions".into(),
                 r#type: Default::default(),
diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs
index 4fe316a..8f00c8e 100644
--- a/crates/settings_ui/src/pages.rs
+++ b/crates/settings_ui/src/pages.rs
@@ -1,3 +1,4 @@
+mod agent_jail_page;
 mod audio_input_output_setup;
 mod audio_test_window;
 mod edit_prediction_provider_setup;
@@ -10,6 +11,7 @@ mod skill_creator;
 mod skills_setup;
 mod tool_permissions_setup;
 
+pub(crate) use agent_jail_page::{render_agent_jail_page, render_agent_memory_page};
 pub(crate) use audio_input_output_setup::{
     render_input_audio_device_dropdown, render_output_audio_device_dropdown,
 };
diff --git a/crates/settings_ui/src/pages/agent_jail_page.rs b/crates/settings_ui/src/pages/agent_jail_page.rs
new file mode 100644
index 0000000..b2f17d0
--- /dev/null
+++ b/crates/settings_ui/src/pages/agent_jail_page.rs
@@ -0,0 +1,1672 @@
+//! The "Agent Sandbox (ai-jail)" sub-page (R3, R4, R7.3): the jail switches, the writable folders
+//! and allowed hosts of the active local project, the ai-jail binary, and the generated command.
+//! Below them, and alone on the "Agent Memory (ai-memory)" sub-page, the Memory section (R6).
+//!
+//! The switches are written by `agent_sandbox::confirm` only, which asks before turning the jail
+//! off. The lists are written here, without asking (R4.4), after the checks a start would make.
+
+use std::cell::Cell;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::sync::{Arc, OnceLock};
+
+use agent_sandbox::command::{self, JailInputs};
+use agent_sandbox::confirm::{self, non_utf8_root_message, target_project_key};
+use agent_sandbox::launch::{self, MEMORY_PORT_UNSUPPORTED, MemoryPort};
+use agent_sandbox::memory::{
+    self, MemoryClient, MemoryScope, MemoryStatus, RecentPage, ResolvedBy, display_endpoint,
+};
+use agent_sandbox::probe::{self, JailBinary};
+use agent_sandbox::validate::{self, ProtectedSet, expand_tilde};
+use agent_sandbox::{
+    AgentMemorySettings, AgentSandboxSettings, LaunchRegistry, ProjectPolicy, lexical_match,
+    resolve_project_key,
+};
+use collections::HashMap;
+use fs::Fs;
+use futures::channel::oneshot;
+use gpui::{
+    AsyncApp, BackgroundExecutor, Bounds, ClipboardItem, Entity, FocusHandle, Pixels, ScrollHandle,
+    Subscription, Task, WeakEntity, point,
+};
+use project::agent_server_store::AgentServerCommand;
+use semver::Version;
+use settings::{AgentSandboxProjectSettingsContent, Settings as _, SettingsStore};
+use ui::{Banner, Divider, Severity, SwitchField, ToggleState, Tooltip, prelude::*};
+use util::ResultExt as _;
+
+use crate::SettingsWindow;
+use crate::components::{SettingsInputField, SettingsSectionHeader};
+
+const NO_PROJECT_NOTICE: &str = "Open a local project to configure it.";
+const NOT_INSTALLED: &str = "ai-jail is not installed. Install it with `emerge sys-apps/ai-jail`, or enable USE `ai-jail` on `app-editors/zeo`.";
+const AI_JAIL_FILE_NOTICE: &str = "This project has an .ai-jail file. ai-jail reads it too, and lets it only narrow the generated jail, never widen it.";
+const PREVIEW_OFF: &str = "Sandbox off: the agent starts without ai-jail.";
+const INVALID_ENDPOINT_NOTE: &str =
+    "No port is forwarded into the jail: the ai-memory endpoint is not a valid URL.";
+const RESTART_NOTICE: &str = "Restart the agent to apply.";
+/// The adapter the preview wraps; a start wraps whichever Claude adapter the agent runs.
+const PREVIEW_AGENT: &str = "claude-agent-acp-plus";
+
+/// One of the two editable lists of a project.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub(crate) enum EntryKind {
+    RwMap,
+    AllowHost,
+}
+
+impl EntryKind {
+    fn noun(self) -> &'static str {
+        match self {
+            Self::RwMap => "Writable folder",
+            Self::AllowHost => "Allowed host",
+        }
+    }
+
+    fn field(self) -> &'static str {
+        match self {
+            Self::RwMap => "rw_maps",
+            Self::AllowHost => "allow_hosts",
+        }
+    }
+
+    fn id(self) -> &'static str {
+        match self {
+            Self::RwMap => "rw-map",
+            Self::AllowHost => "allow-host",
+        }
+    }
+
+    fn list(self, entry: &agent_sandbox::AgentSandboxProjectSettings) -> &[String] {
+        match self {
+            Self::RwMap => &entry.rw_maps,
+            Self::AllowHost => &entry.allow_hosts,
+        }
+    }
+
+    fn defaults(self, sandbox: &AgentSandboxSettings) -> &[String] {
+        match self {
+            Self::RwMap => &sandbox.rw_maps,
+            Self::AllowHost => &sandbox.allow_hosts,
+        }
+    }
+
+    fn list_mut(self, entry: &mut AgentSandboxProjectSettingsContent) -> &mut Vec<String> {
+        match self {
+            Self::RwMap => entry.rw_maps.get_or_insert_default(),
+            Self::AllowHost => entry.allow_hosts.get_or_insert_default(),
+        }
+    }
+}
+
+/// Where the protected folders are, for a writable folder's checks. Tests substitute a home.
+pub(crate) struct EntryEnv {
+    pub home: PathBuf,
+    pub config_dir: PathBuf,
+}
+
+impl EntryEnv {
+    fn from_process() -> Self {
+        Self {
+            home: paths::home_dir().clone(),
+            config_dir: paths::config_dir().clone(),
+        }
+    }
+}
+
+/// The R3.6 banner when ai-jail was not found (`None`), the R3.5 warning when it is older than
+/// 2.4.0, and nothing for a safe version.
+pub(crate) fn banner_text(installed: Option<&Version>) -> Option<String> {
+    match installed {
+        None => Some(NOT_INSTALLED.to_string()),
+        Some(version) if probe::is_outdated(version) => Some(probe::outdated_warning(version)),
+        Some(_) => None,
+    }
+}
+
+/// The R3.3 text when no local project is active.
+pub(crate) fn project_notice(root: Option<&Path>) -> Option<&'static str> {
+    root.is_none().then_some(NO_PROJECT_NOTICE)
+}
+
+/// The R3.9 notice, only when `root/.ai-jail` is a file: a folder of that name is not what
+/// ai-jail reads.
+pub(crate) async fn ai_jail_file_notice(fs: &dyn Fs, root: &Path) -> Option<&'static str> {
+    let path = root.join(".ai-jail");
+    match fs.metadata(&path).await {
+        Ok(Some(metadata)) if !metadata.is_dir => Some(AI_JAIL_FILE_NOTICE),
+        Ok(_) => None,
+        Err(error) => {
+            log::warn!(
+                "agent_sandbox event=ai_jail_file_check_failed path={:?} error={:?}",
+                path.to_string_lossy(),
+                first_line(&error)
+            );
+            None
+        }
+    }
+}
+
+/// The R3.10 sentence, only when memory is on, the endpoint is loopback and this ai-jail has no
+/// `--forward-port`: the same decision a start makes (R7.4).
+pub(crate) fn forward_port_notice(
+    memory: &AgentMemorySettings,
+    supports_forward_port: bool,
+) -> Option<&'static str> {
+    matches!(
+        launch::memory_port(memory, supports_forward_port),
+        Ok(MemoryPort::Unsupported)
+    )
+    .then_some(MEMORY_PORT_UNSUPPORTED)
+}
+
+/// The R7.3 note while memory is on and the endpoint is not loopback. It names the host only:
+/// the endpoint may carry a token in its userinfo or query, and an unparsable one is never shown.
+pub(crate) fn memory_endpoint_note(memory: &AgentMemorySettings) -> Option<String> {
+    match launch::memory_port(memory, true) {
+        Ok(MemoryPort::NonLoopback) => {
+            let host = url::Url::parse(&memory.endpoint)
+                .ok()
+                .and_then(|endpoint| endpoint.host_str().map(str::to_string));
+            Some(match host {
+                Some(host) => format!(
+                    "No port is forwarded into the jail for ai-memory at {host}. Add {host} to the allowed hosts for the jailed agent to reach it."
+                ),
+                None => INVALID_ENDPOINT_NOTE.to_string(),
+            })
+        }
+        Ok(MemoryPort::Forwarded(_) | MemoryPort::Unsupported | MemoryPort::Off) => None,
+        Err(_) => Some(INVALID_ENDPOINT_NOTE.to_string()),
+    }
+}
+
+/// What the command block says instead of a command while the sandbox is off for the project.
+pub(crate) fn preview_off_notice(enabled: bool) -> Option<&'static str> {
+    (!enabled).then_some(PREVIEW_OFF)
+}
+
+/// The `projects` key the page shows for `root`: the lexical match, else `resolved` (the key the
+/// background resolution found through a symlink) while the settings still hold it. Reads no
+/// file system.
+pub(crate) fn page_key<'a>(
+    sandbox: &'a AgentSandboxSettings,
+    root: &Path,
+    resolved: Option<&str>,
+) -> Option<&'a str> {
+    lexical_match(sandbox.project_keys(), root).or_else(|| {
+        resolved
+            .and_then(|key| sandbox.projects.get_key_value(key))
+            .map(|(key, _)| key.as_str())
+    })
+}
+
+/// The command a jailed start of the project would run, from [`command::generate`]. Reads no file
+/// system: folders are shown as written with `~` expanded, while a start canonicalizes them.
+/// `--forward-port` appears only when [`launch::memory_port`] forwards one, and then the agent runs
+/// through [`command::keep_loopback_off_proxy`]'s `/bin/sh`, as a start runs it (R7.1).
+pub(crate) fn preview_command(
+    policy: &ProjectPolicy,
+    root: &Path,
+    claude_config_dir: &str,
+    ai_jail: Option<&Path>,
+    memory: &AgentMemorySettings,
+    supports_forward_port: bool,
+) -> String {
+    let home = paths::home_dir();
+    let claude_dir = expand_tilde(claude_config_dir, home);
+    let mut rw_maps: Vec<PathBuf> = Vec::with_capacity(policy.rw_maps.len());
+    for raw in &policy.rw_maps {
+        let map = expand_tilde(raw, home);
+        if map != claude_dir && !rw_maps.contains(&map) {
+            rw_maps.push(map);
+        }
+    }
+    let forward_ports = match launch::memory_port(memory, supports_forward_port) {
+        Ok(MemoryPort::Forwarded(port)) => vec![port],
+        Ok(MemoryPort::Unsupported | MemoryPort::Off | MemoryPort::NonLoopback) | Err(_) => {
+            Vec::new()
+        }
+    };
+    let inputs = JailInputs {
+        ai_jail: ai_jail.unwrap_or(Path::new("ai-jail")),
+        project_root: root,
+        claude_dir: &claude_dir,
+        rw_maps: &rw_maps,
+        allow_hosts: &policy.allow_hosts,
+        forward_ports: &forward_ports,
+        env_names: Vec::new(),
+    };
+    let agent = AgentServerCommand {
+        path: PathBuf::from(PREVIEW_AGENT),
+        args: Vec::new(),
+        env: None,
+    };
+    let jailed = command::generate(agent, &inputs);
+    if forward_ports.is_empty() {
+        command::preview(&jailed)
+    } else {
+        command::preview(&command::keep_loopback_off_proxy(jailed))
+    }
+}
+
+/// Adds `raw` to the project's list after the checks a start makes (R2.4–R2.6 and the nesting
+/// rule), without asking (R4.4). On a refusal the rule text is returned and nothing is written.
+pub(crate) fn add_entry(
+    kind: EntryKind,
+    root: &Path,
+    raw: &str,
+    cx: &mut App,
+) -> Task<Result<(), String>> {
+    add_entry_in(kind, root, raw, EntryEnv::from_process(), cx)
+}
+
+pub(crate) fn add_entry_in(
+    kind: EntryKind,
+    root: &Path,
+    raw: &str,
+    env: EntryEnv,
+    cx: &mut App,
+) -> Task<Result<(), String>> {
+    let Some(root_text) = root.to_str().map(str::to_string) else {
+        return Task::ready(Err(non_utf8_root_message(root)));
+    };
+    let sandbox = AgentSandboxSettings::get_global(cx).clone();
+    let check = cx.background_spawn({
+        let root = root.to_path_buf();
+        let raw = raw.to_string();
+        async move { check_new_entry(kind, &root, &raw, &sandbox, &env) }
+    });
+    let raw = raw.to_string();
+    cx.spawn(async move |cx| {
+        let resolved = check.await?;
+        write_list(kind, root_text, resolved, ListEdit::Add(raw), cx).await
+    })
+}
+
+/// Removes `raw` from the project's own list, without asking (R4.4). The project's entry stays,
+/// `enabled` with it; an emptied list is written as `[]`.
+pub(crate) fn remove_entry(
+    kind: EntryKind,
+    root: &Path,
+    raw: &str,
+    cx: &mut App,
+) -> Task<Result<(), String>> {
+    let Some(root_text) = root.to_str().map(str::to_string) else {
+        return Task::ready(Err(non_utf8_root_message(root)));
+    };
+    let sandbox = AgentSandboxSettings::get_global(cx).clone();
+    let check = cx.background_spawn({
+        let root = root.to_path_buf();
+        let raw = raw.to_string();
+        async move { check_removal(kind, &root, &raw, &sandbox) }
+    });
+    let raw = raw.to_string();
+    cx.spawn(async move |cx| {
+        let resolved = check.await?;
+        write_list(kind, root_text, Some(resolved), ListEdit::Remove(raw), cx).await
+    })
+}
+
+/// The checks of an added entry, in a start's order. Blocking: it canonicalizes paths and scans
+/// the protected folders. Returns the `projects` key `root` resolves to.
+fn check_new_entry(
+    kind: EntryKind,
+    root: &Path,
+    raw: &str,
+    sandbox: &AgentSandboxSettings,
+    env: &EntryEnv,
+) -> Result<Option<String>, String> {
+    let resolved = resolve_project_key(sandbox.project_keys(), root).map(str::to_string);
+    let policy = sandbox.policy_for_key(resolved.as_deref());
+    let already_listed = || format!("{} \"{raw}\" is already in the list.", kind.noun());
+    match kind {
+        EntryKind::AllowHost => {
+            let host = validate::validate_host(raw).map_err(|error| error.to_string())?;
+            validate::check_limits(0, policy.allow_hosts.len() + 1)
+                .map_err(|error| error.to_string())?;
+            if policy.allow_hosts.contains(&host) {
+                return Err(already_listed());
+            }
+        }
+        EntryKind::RwMap => {
+            let set = ProtectedSet::from_env(&env.home, &env.config_dir);
+            let map =
+                validate::validate_rw_map_with(raw, &set).map_err(|error| error.to_string())?;
+            // An invalid Claude dir refuses every start anyway; its spelled path still nests.
+            let claude_dir = validate::validate_rw_map_with(&sandbox.claude_config_dir, &set)
+                .unwrap_or_else(|_| expand_tilde(&sandbox.claude_config_dir, &env.home));
+            let mut maps: Vec<PathBuf> = Vec::with_capacity(policy.rw_maps.len() + 1);
+            for existing in &policy.rw_maps {
+                if existing == raw {
+                    return Err(already_listed());
+                }
+                if let Ok(existing) = validate::validate_rw_map_with(existing, &set)
+                    && existing != claude_dir
+                    && !maps.contains(&existing)
+                {
+                    maps.push(existing);
+                }
+            }
+            if map == claude_dir {
+                return Err(format!(
+                    "Writable folder \"{raw}\" is the Claude config directory, which the jail always maps."
+                ));
+            }
+            if maps.contains(&map) {
+                return Err(already_listed());
+            }
+            maps.push(map);
+            validate::check_limits(maps.len(), 0).map_err(|error| error.to_string())?;
+            let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
+            launch::check_nesting(&canonical_root, &claude_dir, &maps)
+                .map_err(|error| error.to_string())?;
+        }
+    }
+    Ok(resolved)
+}
+
+/// A removal names an entry of the project's own list; a default is edited in settings.json.
+/// Blocking. Returns the `projects` key holding the entry.
+fn check_removal(
+    kind: EntryKind,
+    root: &Path,
+    raw: &str,
+    sandbox: &AgentSandboxSettings,
+) -> Result<String, String> {
+    let resolved = resolve_project_key(sandbox.project_keys(), root);
+    if let Some(key) = resolved
+        && sandbox
+            .projects
+            .get(key)
+            .is_some_and(|entry| kind.list(entry).iter().any(|entry| entry == raw))
+    {
+        return Ok(key.to_string());
+    }
+    if kind.defaults(sandbox).iter().any(|entry| entry == raw) {
+        return Err(format!(
+            "{} \"{raw}\" applies to every project; remove it from agent_sandbox.{} in settings.json.",
+            kind.noun(),
+            kind.field()
+        ));
+    }
+    Err(format!(
+        "{} \"{raw}\" is not in this project's list.",
+        kind.noun()
+    ))
+}
+
+enum ListEdit {
+    Add(String),
+    Remove(String),
+}
+
+/// Writes one list of the project's entry and nothing else: the key is chosen with
+/// [`confirm::target_project_key`], as the switch writer does, so an edit through a symlinked
+/// root never adds a second key without `enabled`.
+async fn write_list(
+    kind: EntryKind,
+    root_text: String,
+    resolved: Option<String>,
+    edit: ListEdit,
+    cx: &mut AsyncApp,
+) -> Result<(), String> {
+    let written_key = Arc::new(OnceLock::new());
+    let receiver = cx.update(|cx| {
+        let written_key = written_key.clone();
+        cx.global::<SettingsStore>()
+            .update_settings_file_with_completion(<dyn Fs>::global(cx), move |content, _| {
+                let projects = content
+                    .agent_sandbox
+                    .get_or_insert_default()
+                    .projects
+                    .get_or_insert_default();
+                let key = target_project_key(
+                    projects.keys(),
+                    &root_text,
+                    resolved.as_deref().map(Path::new),
+                );
+                written_key
+                    .set(format!("agent_sandbox.projects[{key}].{}", kind.field()))
+                    .ok();
+                match edit {
+                    ListEdit::Add(raw) => {
+                        let list = kind.list_mut(projects.entry(key).or_default());
+                        if !list.contains(&raw) {
+                            list.push(raw);
+                        }
+                    }
+                    ListEdit::Remove(raw) => {
+                        if let Some(entry) = projects.get_mut(&key) {
+                            kind.list_mut(entry).retain(|entry| *entry != raw);
+                        }
+                    }
+                }
+            })
+    });
+    let error = match receiver.await {
+        Ok(Ok(())) => return Ok(()),
+        Ok(Err(error)) => error,
+        Err(oneshot::Canceled) => anyhow::anyhow!("the settings writer stopped before saving"),
+    };
+    let key = written_key
+        .get()
+        .cloned()
+        .unwrap_or_else(|| format!("agent_sandbox.projects.{}", kind.field()));
+    let error = first_line(&error);
+    log::error!("agent_sandbox event=settings_write_failed key={key:?} error={error:?}");
+    Err(format!("Could not save the agent sandbox setting: {error}"))
+}
+
+fn first_line(error: &anyhow::Error) -> String {
+    format!("{error:#}")
+        .lines()
+        .next()
+        .unwrap_or_default()
+        .to_string()
+}
+
+/// The page's Tab stops, each wrapped so that the page scrolls it into view when it takes focus.
+/// On a settings sub-page Tab only calls `focus_next`, and an `overflow_y_scroll` container does not
+/// follow focus, so without this every control below the fold is focused out of sight (Q16).
+#[derive(Default)]
+struct RevealOnFocus {
+    controls: HashMap<SharedString, RevealedControl>,
+}
+
+#[derive(Clone)]
+struct RevealedControl {
+    /// Never a tab stop: it only tells whether focus is inside the wrapped control.
+    focus_handle: FocusHandle,
+    /// Set once the control was scrolled into view for its current focus, so that scrolling
+    /// away with the wheel while it keeps focus is not undone on the next frame.
+    revealed: Rc<Cell<bool>>,
+}
+
+impl RevealOnFocus {
+    fn wrap(
+        &mut self,
+        key: impl Into<SharedString>,
+        scroll_handle: &ScrollHandle,
+        control: impl IntoElement,
+        cx: &mut App,
+    ) -> AnyElement {
+        let key = key.into();
+        let RevealedControl {
+            focus_handle,
+            revealed,
+        } = self
+            .controls
+            .entry(key.clone())
+            .or_insert_with(|| RevealedControl {
+                focus_handle: cx.focus_handle().tab_stop(false),
+                revealed: Rc::default(),
+            })
+            .clone();
+        let scroll_handle = scroll_handle.clone();
+        // Only the test build reads `key` after the selector closure takes it.
+        #[cfg(test)]
+        let focused_key = key.clone();
+        div()
+            .track_focus(&focus_handle)
+            .debug_selector(move || key.to_string())
+            .child(control)
+            .on_children_prepainted(move |bounds, window, cx| {
+                let focused = focus_handle.contains_focused(window, cx);
+                #[cfg(test)]
+                record_focused_control(&focused_key, focused);
+                if !focused {
+                    revealed.set(false);
+                    return;
+                }
+                if revealed.replace(true) {
+                    return;
+                }
+                if let Some(bounds) = bounds.first()
+                    && scroll_into_view(&scroll_handle, *bounds)
+                {
+                    window.request_animation_frame();
+                }
+            })
+            .into_any_element()
+    }
+}
+
+/// Scrolls `scroll_handle` the least that brings `control` inside its viewport, or to the
+/// control's top when it is taller than the viewport. Returns whether the offset changed.
+fn scroll_into_view(scroll_handle: &ScrollHandle, control: Bounds<Pixels>) -> bool {
+    let viewport = scroll_handle.bounds();
+    if viewport.size.height <= Pixels::ZERO {
+        return false;
+    }
+    // The offset is negative once scrolled down: a control below the viewport needs a lower one.
+    let delta = if control.top() < viewport.top() {
+        viewport.top() - control.top()
+    } else if control.bottom() > viewport.bottom() {
+        (viewport.bottom() - control.bottom()).max(viewport.top() - control.top())
+    } else {
+        return false;
+    };
+    let offset = scroll_handle.offset();
+    let y = (offset.y + delta).clamp(-scroll_handle.max_offset().y, Pixels::ZERO);
+    if y == offset.y {
+        return false;
+    }
+    scroll_handle.set_offset(point(offset.x, y));
+    true
+}
+
+#[cfg(test)]
+thread_local! {
+    static FOCUSED_CONTROL: std::cell::RefCell<Option<SharedString>> =
+        const { std::cell::RefCell::new(None) };
+}
+
+#[cfg(test)]
+fn record_focused_control(key: &SharedString, focused: bool) {
+    FOCUSED_CONTROL.with_borrow_mut(|current| {
+        if focused {
+            *current = Some(key.clone());
+        } else if current.as_ref() == Some(key) {
+            *current = None;
+        }
+    });
+}
+
+/// The wrapped control that held focus at the last frame, by its key: the tests' only view of
+/// which element GPUI focused, since an element's own focus handle is internal to it.
+#[cfg(test)]
+pub(crate) fn focused_control() -> Option<SharedString> {
+    FOCUSED_CONTROL.with_borrow(Clone::clone)
+}
+
+/// What the background probe found.
+#[derive(Clone)]
+enum JailProbe {
+    Pending,
+    Missing { configured: Option<PathBuf> },
+    Found { path: PathBuf, binary: JailBinary },
+    Unreadable { path: PathBuf, error: String },
+}
+
+/// Locates and probes ai-jail as a start does.
+async fn probe_jail(configured: Option<PathBuf>, executor: BackgroundExecutor) -> JailProbe {
+    let path = match probe::locate(configured.as_deref()) {
+        Some(path) if path.is_file() => path,
+        located => {
+            return JailProbe::Missing {
+                configured: located.filter(|_| configured.is_some()),
+            };
+        }
+    };
+    match probe::probe(&path, &executor).await {
+        Ok(binary) => JailProbe::Found { path, binary },
+        Err(error) => JailProbe::Unreadable {
+            path,
+            error: error.to_string(),
+        },
+    }
+}
+
+/// Everything the page reads from disk, refreshed in the background whenever the settings change,
+/// so rendering does no I/O.
+struct JailPageState {
+    root: Option<PathBuf>,
+    jail: JailProbe,
+    /// The `projects` key the root resolves to through a symlink, once resolved.
+    resolved_key: Option<String>,
+    ai_jail_file: Option<&'static str>,
+    rw_map_error: Option<String>,
+    host_error: Option<String>,
+    controls: RevealOnFocus,
+    refresh_task: Task<()>,
+    _settings_subscription: Subscription,
+}
+
+impl JailPageState {
+    fn new(root: Option<PathBuf>, cx: &mut Context<Self>) -> Self {
+        let settings_subscription = cx.observe_global::<SettingsStore>(|this, cx| this.refresh(cx));
+        let mut this = Self {
+            root,
+            jail: JailProbe::Pending,
+            resolved_key: None,
+            ai_jail_file: None,
+            rw_map_error: None,
+            host_error: None,
+            controls: RevealOnFocus::default(),
+            refresh_task: Task::ready(()),
+            _settings_subscription: settings_subscription,
+        };
+        this.refresh(cx);
+        this
+    }
+
+    fn refresh(&mut self, cx: &mut Context<Self>) {
+        let sandbox = AgentSandboxSettings::get_global(cx).clone();
+        let root = self.root.clone();
+        let fs = <dyn Fs>::global(cx);
+        let executor = cx.background_executor().clone();
+        self.refresh_task = cx.spawn(async move |this, cx| {
+            let jail = cx.background_spawn(probe_jail(sandbox.ai_jail_path.clone(), executor));
+            let (resolved_key, ai_jail_file) = match root {
+                Some(root) => {
+                    let resolved = cx.background_spawn({
+                        let root = root.clone();
+                        async move {
+                            resolve_project_key(sandbox.project_keys(), &root).map(str::to_string)
+                        }
+                    });
+                    let notice = ai_jail_file_notice(fs.as_ref(), &root).await;
+                    (resolved.await, notice)
+                }
+                None => (None, None),
+            };
+            let jail = jail.await;
+            this.update(cx, |this, cx| {
+                this.jail = jail;
+                this.resolved_key = resolved_key;
+                this.ai_jail_file = ai_jail_file;
+                cx.notify();
+            })
+            .log_err();
+        });
+    }
+
+    fn error_mut(&mut self, kind: EntryKind) -> &mut Option<String> {
+        match kind {
+            EntryKind::RwMap => &mut self.rw_map_error,
+            EntryKind::AllowHost => &mut self.host_error,
+        }
+    }
+
+    fn add(&mut self, kind: EntryKind, raw: String, cx: &mut Context<Self>) {
+        let Some(root) = self.root.clone() else {
+            return;
+        };
+        let task = add_entry(kind, &root, &raw, cx);
+        self.show_outcome(kind, task, cx);
+    }
+
+    fn remove(&mut self, kind: EntryKind, raw: String, cx: &mut Context<Self>) {
+        let Some(root) = self.root.clone() else {
+            return;
+        };
+        let task = remove_entry(kind, &root, &raw, cx);
+        self.show_outcome(kind, task, cx);
+    }
+
+    /// Shows the edit's error under its list's input, or clears it on success.
+    fn show_outcome(
+        &mut self,
+        kind: EntryKind,
+        task: Task<Result<(), String>>,
+        cx: &mut Context<Self>,
+    ) {
+        *self.error_mut(kind) = None;
+        cx.notify();
+        cx.spawn(async move |this, cx| {
+            let result = task.await;
+            this.update(cx, |this, cx| {
+                *this.error_mut(kind) = result.err();
+                cx.notify();
+            })
+            .log_err();
+        })
+        .detach();
+    }
+}
+
+pub(crate) fn render_agent_jail_page(
+    settings_window: &SettingsWindow,
+    scroll_handle: &ScrollHandle,
+    window: &mut Window,
+    cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+    let root = active_root(settings_window, cx);
+    let state_key = format!(
+        "agent-jail-page-{}",
+        root.as_deref()
+            .map(|root| root.to_string_lossy())
+            .unwrap_or_default()
+    );
+    let state = window.use_keyed_state(SharedString::from(state_key), cx, {
+        let root = root.clone();
+        move |_, cx| JailPageState::new(root, cx)
+    });
+    let memory_section = memory_section(root.clone(), scroll_handle, window, cx);
+    let sandbox = AgentSandboxSettings::get_global(cx).clone();
+    let memory = AgentMemorySettings::get_global(cx).clone();
+    let (jail, resolved_key, ai_jail_file, rw_map_error, host_error) = {
+        let page = state.read(cx);
+        (
+            page.jail.clone(),
+            page.resolved_key.clone(),
+            page.ai_jail_file,
+            page.rw_map_error.clone(),
+            page.host_error.clone(),
+        )
+    };
+
+    let project_section = match root.as_deref() {
+        Some(root) => {
+            let key = page_key(&sandbox, root, resolved_key.as_deref());
+            let policy = sandbox.policy_for_key(key);
+            let own = key
+                .and_then(|key| sandbox.projects.get(key))
+                .cloned()
+                .unwrap_or_default();
+            let drifted = LaunchRegistry::for_root(root, cx)
+                .iter()
+                .any(|record| launch::drifted_now(record, cx));
+            let (ai_jail_path, supports_forward_port) = match &jail {
+                JailProbe::Found { path, binary } => {
+                    (Some(path.as_path()), Some(binary.supports_forward_port))
+                }
+                JailProbe::Unreadable { path, .. } => (Some(path.as_path()), None),
+                JailProbe::Pending | JailProbe::Missing { .. } => (None, None),
+            };
+            let preview = preview_command(
+                &policy,
+                root,
+                &sandbox.claude_config_dir,
+                ai_jail_path,
+                &memory,
+                supports_forward_port.unwrap_or(false),
+            );
+            // The R7.3 note is shown once, beside the Memory switch.
+            let memory_notices: Vec<String> = supports_forward_port
+                .and_then(|supports| forward_port_notice(&memory, supports))
+                .map(str::to_string)
+                .into_iter()
+                .collect();
+            let lists = [
+                (
+                    EntryKind::RwMap,
+                    "Writable Folders",
+                    "Folders the jailed agent may write besides the project. Each must be an absolute path to an existing folder, outside the project, the Claude config directory and every other writable folder.",
+                    "Add a writable folder (e.g. ~/data)…",
+                    policy.rw_maps.clone(),
+                    rw_map_error,
+                ),
+                (
+                    EntryKind::AllowHost,
+                    "Allowed Hosts",
+                    "Hosts the jailed agent may reach. Each is a bare DNS name: no scheme, port, path, wildcard or IP address.",
+                    "Add an allowed host (e.g. registry.npmjs.org)…",
+                    policy.allow_hosts.clone(),
+                    host_error,
+                ),
+            ]
+            .into_iter()
+            .map(
+                |(kind, title, description, placeholder, entries, error)| {
+                    render_list(
+                        kind,
+                        ListText {
+                            title,
+                            description,
+                            placeholder,
+                        },
+                        entries,
+                        kind.list(&own),
+                        error,
+                        &state,
+                        scroll_handle,
+                        cx,
+                    )
+                },
+            )
+            .collect::<Vec<_>>();
+            let project_switch = SwitchField::new(
+                "agent-jail-project-enabled",
+                Some("Run agents in ai-jail for this project"),
+                Some(
+                    "Start the Claude agents of this project inside ai-jail. Turning it off asks first."
+                        .into(),
+                ),
+                policy.enabled,
+                {
+                    let root = root.to_path_buf();
+                    move |toggle, window, cx| {
+                        confirm::set_project_enabled(
+                            &root,
+                            *toggle == ToggleState::Selected,
+                            window,
+                            cx,
+                        )
+                        .detach();
+                    }
+                },
+            )
+            .tab_index(0);
+            let project_switch = reveal(
+                &state,
+                "agent-jail-project-enabled",
+                scroll_handle,
+                project_switch,
+                cx,
+            );
+            let preview = render_preview(
+                preview_off_notice(policy.enabled),
+                preview,
+                memory_notices,
+                &state,
+                scroll_handle,
+                cx,
+            );
+
+            v_flex()
+                .gap_4()
+                .child(SettingsSectionHeader::new("This Project").no_padding(true))
+                .child(
+                    Label::new(root.to_string_lossy().into_owned())
+                        .size(LabelSize::Small)
+                        .color(Color::Muted)
+                        .buffer_font(cx),
+                )
+                .child(project_switch)
+                .when(drifted, |this| {
+                    this.child(
+                        Label::new(RESTART_NOTICE)
+                            .size(LabelSize::Small)
+                            .color(Color::Warning),
+                    )
+                })
+                .when_some(ai_jail_file, |this, notice| {
+                    this.child(
+                        Label::new(notice)
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                })
+                .children(lists)
+                .child(preview)
+                .into_any_element()
+        }
+        None => Label::new(project_notice(None).unwrap_or(NO_PROJECT_NOTICE))
+            .color(Color::Muted)
+            .into_any_element(),
+    };
+
+    let defaults = render_defaults(&sandbox, &state, scroll_handle, cx);
+    v_flex()
+        .id("agent-jail-page")
+        .debug_selector(|| "agent-jail-page".into())
+        .size_full()
+        .pt_2p5()
+        .px_8()
+        .pb_16()
+        .gap_6()
+        .overflow_y_scroll()
+        .track_scroll(scroll_handle)
+        .child(render_binary(&jail, cx))
+        .child(Divider::horizontal())
+        .child(project_section)
+        .child(Divider::horizontal())
+        .child(defaults)
+        .child(Divider::horizontal())
+        .child(memory_section)
+        .into_any_element()
+}
+
+/// `control`, wrapped by the page's [`RevealOnFocus`] under `key`.
+fn reveal(
+    state: &Entity<JailPageState>,
+    key: impl Into<SharedString>,
+    scroll_handle: &ScrollHandle,
+    control: impl IntoElement,
+    cx: &mut App,
+) -> AnyElement {
+    state.update(cx, |state, cx| {
+        state.controls.wrap(key, scroll_handle, control, cx)
+    })
+}
+
+/// The ai-jail path and version (R3.2), with the R3.5 or R3.6 banner.
+fn render_binary(jail: &JailProbe, cx: &App) -> AnyElement {
+    let warning = |text: String| {
+        Banner::new()
+            .severity(Severity::Warning)
+            .child(Label::new(text).size(LabelSize::Small))
+    };
+    let body = match jail {
+        JailProbe::Pending => Label::new("Looking for ai-jail…")
+            .size(LabelSize::Small)
+            .color(Color::Muted)
+            .into_any_element(),
+        JailProbe::Missing { configured } => v_flex()
+            .gap_1()
+            .when_some(banner_text(None), |this, text| this.child(warning(text)))
+            .when_some(configured.as_ref(), |this, path| {
+                this.child(
+                    Label::new(format!(
+                        "Not found at {} (agent_sandbox.ai_jail_path).",
+                        path.display()
+                    ))
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+                )
+            })
+            .into_any_element(),
+        JailProbe::Found { path, binary } => v_flex()
+            .gap_1()
+            .child(
+                Label::new(format!("ai-jail {} at {}", binary.version, path.display()))
+                    .size(LabelSize::Small)
+                    .buffer_font(cx),
+            )
+            .when_some(banner_text(Some(&binary.version)), |this, text| {
+                this.child(warning(text))
+            })
+            .into_any_element(),
+        JailProbe::Unreadable { path, error } => v_flex()
+            .gap_1()
+            .child(
+                Label::new(path.to_string_lossy().into_owned())
+                    .size(LabelSize::Small)
+                    .buffer_font(cx),
+            )
+            .child(
+                Banner::new().severity(Severity::Error).child(
+                    Label::new(format!(
+                        "The version of ai-jail could not be read: {error}."
+                    ))
+                    .size(LabelSize::Small),
+                ),
+            )
+            .into_any_element(),
+    };
+    v_flex()
+        .gap_2()
+        .child(SettingsSectionHeader::new("ai-jail").no_padding(true))
+        .child(body)
+        .into_any_element()
+}
+
+struct ListText {
+    title: &'static str,
+    description: &'static str,
+    placeholder: &'static str,
+}
+
+/// One editable list: the merged entries, a remove button on the project's own ones, an add input
+/// and the refusal under it (R3.4).
+fn render_list(
+    kind: EntryKind,
+    text: ListText,
+    entries: Vec<String>,
+    own: &[String],
+    error: Option<String>,
+    state: &Entity<JailPageState>,
+    scroll_handle: &ScrollHandle,
+    cx: &mut App,
+) -> AnyElement {
+    let mut rows = Vec::with_capacity(entries.len());
+    for (index, entry) in entries.into_iter().enumerate() {
+        let action = if own.contains(&entry) {
+            let remove_label = format!("Remove {}", entry);
+            let remove_id = format!("agent-jail-{}-remove-{index}", kind.id());
+            let button = IconButton::new(SharedString::from(remove_id.clone()), IconName::Trash)
+                .icon_size(IconSize::Small)
+                .icon_color(Color::Muted)
+                .tab_index(0_isize)
+                .aria_label(remove_label.clone())
+                .tooltip(Tooltip::text(remove_label))
+                .on_click({
+                    let state = state.clone();
+                    let entry = entry.clone();
+                    move |_, _, cx| {
+                        let entry = entry.clone();
+                        state.update(cx, |state, cx| state.remove(kind, entry, cx));
+                    }
+                });
+            reveal(state, remove_id, scroll_handle, button, cx)
+        } else {
+            Label::new("Default for every project")
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+                .into_any_element()
+        };
+        rows.push(
+            h_flex()
+                .gap_2()
+                .justify_between()
+                .child(Label::new(entry).size(LabelSize::Small).buffer_font(cx))
+                .child(action),
+        );
+    }
+    let input_id = format!("agent-jail-{}-new", kind.id());
+    let input = SettingsInputField::new(SharedString::from(input_id.clone()))
+        .with_placeholder(text.placeholder)
+        .aria_label(format!("Add to {}", text.title))
+        .tab_index(0)
+        .with_buffer_font()
+        .display_clear_button()
+        .display_confirm_button()
+        .clear_on_confirm()
+        .on_confirm({
+            let state = state.clone();
+            move |raw, _window, cx| {
+                let Some(raw) = raw else {
+                    return;
+                };
+                let raw = raw.trim().to_string();
+                if raw.is_empty() {
+                    return;
+                }
+                state.update(cx, |state, cx| state.add(kind, raw, cx));
+            }
+        });
+    let input = reveal(state, input_id, scroll_handle, input, cx);
+    v_flex()
+        .gap_0p5()
+        .child(Label::new(text.title))
+        .child(
+            Label::new(text.description)
+                .size(LabelSize::Small)
+                .color(Color::Muted),
+        )
+        .child(v_flex().mt_2().gap_1p5().children(rows).child(input))
+        .when_some(error, |this, error| {
+            this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
+        })
+        .into_any_element()
+}
+
+/// The generated command (R3.7), read-only with a Copy button, or what replaces it while off.
+fn render_preview(
+    off_notice: Option<&'static str>,
+    preview: String,
+    memory_notices: Vec<String>,
+    state: &Entity<JailPageState>,
+    scroll_handle: &ScrollHandle,
+    cx: &mut App,
+) -> AnyElement {
+    let body = match off_notice {
+        Some(notice) => Label::new(notice)
+            .size(LabelSize::Small)
+            .color(Color::Muted)
+            .into_any_element(),
+        None => {
+            let command = div()
+                .p_2()
+                .rounded_md()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(
+                    Label::new(preview.clone())
+                        .size(LabelSize::Small)
+                        .buffer_font(cx),
+                );
+            let copy = Button::new("agent-jail-copy-command", "Copy Command")
+                .label_size(LabelSize::Small)
+                .tab_index(0_isize)
+                .tooltip(Tooltip::text("Copy the generated ai-jail command"))
+                .on_click(move |_, _, cx| {
+                    cx.write_to_clipboard(ClipboardItem::new_string(preview.clone()));
+                });
+            v_flex()
+                .gap_1p5()
+                .child(command)
+                .child(h_flex().child(reveal(
+                    state,
+                    "agent-jail-copy-command",
+                    scroll_handle,
+                    copy,
+                    cx,
+                )))
+                .into_any_element()
+        }
+    };
+    v_flex()
+        .gap_0p5()
+        .child(Label::new("Generated Command"))
+        .child(
+            Label::new(
+                "What a start of this project's agent runs. Environment values are never shown.",
+            )
+            .size(LabelSize::Small)
+            .color(Color::Muted),
+        )
+        .child(div().mt_2().child(body))
+        .children(memory_notices.into_iter().map(|notice| {
+            Label::new(notice)
+                .size(LabelSize::Small)
+                .color(Color::Warning)
+        }))
+        .into_any_element()
+}
+
+/// The default switch and the lists every project gets (R3.2, R3.3).
+fn render_defaults(
+    sandbox: &AgentSandboxSettings,
+    state: &Entity<JailPageState>,
+    scroll_handle: &ScrollHandle,
+    cx: &mut App,
+) -> AnyElement {
+    let default_switch = SwitchField::new(
+        "agent-jail-default-enabled",
+        Some("Run agents in ai-jail for projects without their own setting"),
+        Some("The default for every project the switch above was never set for. Turning it off asks first.".into()),
+        sandbox.enabled,
+        |toggle, window, cx| {
+            confirm::set_default_enabled(*toggle == ToggleState::Selected, window, cx).detach();
+        },
+    )
+    .tab_index(0);
+    let default_switch = reveal(
+        state,
+        "agent-jail-default-enabled",
+        scroll_handle,
+        default_switch,
+        cx,
+    );
+    let defaults = |title: &'static str, entries: &[String]| {
+        v_flex()
+            .gap_0p5()
+            .child(Label::new(title).size(LabelSize::Small))
+            .children(if entries.is_empty() {
+                vec![
+                    Label::new("None")
+                        .size(LabelSize::Small)
+                        .color(Color::Disabled)
+                        .into_any_element(),
+                ]
+            } else {
+                entries
+                    .iter()
+                    .map(|entry| {
+                        Label::new(entry.clone())
+                            .size(LabelSize::Small)
+                            .color(Color::Muted)
+                            .buffer_font(cx)
+                            .into_any_element()
+                    })
+                    .collect()
+            })
+    };
+    v_flex()
+        .gap_4()
+        .child(SettingsSectionHeader::new("Other Projects").no_padding(true))
+        .child(default_switch)
+        .child(defaults(
+            "Writable folders for every project (agent_sandbox.rw_maps)",
+            &sandbox.rw_maps,
+        ))
+        .child(defaults(
+            "Allowed hosts for every project (agent_sandbox.allow_hosts)",
+            &sandbox.allow_hosts,
+        ))
+        .into_any_element()
+}
+
+/// How many recent pages the section lists (R6.3).
+const RECENT_LIMIT: usize = 10;
+const MEMORY_OFF: &str = "Memory is off.";
+const MEMORY_NO_PROJECT: &str = "Open a local project to see its memory.";
+const MEMORY_INVALID_ENDPOINT: &str =
+    "The ai-memory endpoint in agent_memory.endpoint is not a valid URL.";
+const FORWARD_PORT_EXPOSURE_NOTE: &str = "While the jail is on, --forward-port exposes the whole ai-memory service to the jailed agent, not only this project's pages.";
+
+/// The Memory section (R6, R7.3): the `agent_memory.enabled` switch, ai-memory's status for the
+/// project's scope, its recent pages, and Forget. While memory is off it makes no call at all.
+pub(crate) struct AgentMemorySection {
+    root: Option<PathBuf>,
+    /// The settings the section last loaded with; a change to them reloads it.
+    memory: AgentMemorySettings,
+    scope: Option<MemoryScope>,
+    status: Option<MemoryStatus>,
+    pages: Vec<RecentPage>,
+    loading: bool,
+    error: Option<String>,
+    forgetting: Option<String>,
+    forget_error: Option<String>,
+    write_error: Option<String>,
+    /// Built inside the load task, for the endpoint it names, and reused until that changes.
+    client: Option<(String, Arc<MemoryClient>)>,
+    load_task: Option<Task<()>>,
+    forget_task: Option<Task<()>>,
+    write_task: Option<Task<()>>,
+    /// The scroll container of the page rendering the section, set on each render of the page.
+    scroll_handle: ScrollHandle,
+    controls: RevealOnFocus,
+    _settings_subscription: Subscription,
+}
+
+impl AgentMemorySection {
+    pub(crate) fn new(root: Option<PathBuf>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let settings_subscription = cx.observe_global::<SettingsStore>(|this, cx| {
+            if *AgentMemorySettings::get_global(cx) != this.memory {
+                this.refresh(cx);
+            }
+        });
+        let mut this = Self {
+            root,
+            memory: AgentMemorySettings::get_global(cx).clone(),
+            scope: None,
+            status: None,
+            pages: Vec::new(),
+            loading: false,
+            error: None,
+            forgetting: None,
+            forget_error: None,
+            write_error: None,
+            client: None,
+            load_task: None,
+            forget_task: None,
+            write_task: None,
+            scroll_handle: ScrollHandle::new(),
+            controls: RevealOnFocus::default(),
+            _settings_subscription: settings_subscription,
+        };
+        this.refresh(cx);
+        this
+    }
+
+    /// Asks ai-memory for the status and the recent pages, once. Nothing retries on its own: an
+    /// error stays shown until the next Refresh or settings change (R6.6).
+    pub(crate) fn refresh(&mut self, cx: &mut Context<Self>) {
+        self.memory = AgentMemorySettings::get_global(cx).clone();
+        self.forget_error = None;
+        if !self.memory.enabled {
+            self.turn_off();
+            cx.notify();
+            return;
+        }
+        let Some(root) = self.root.clone() else {
+            self.load_task = None;
+            self.loading = false;
+            cx.notify();
+            return;
+        };
+        self.loading = true;
+        cx.notify();
+        let fs = <dyn Fs>::global(cx);
+        let scope = cx.background_spawn(async move { memory::resolve_scope(&root, fs).await });
+        let endpoint = self.memory.endpoint.clone();
+        self.load_task = Some(cx.spawn(async move |this, cx| {
+            let scope = scope.await;
+            let client = client_for(&this, &endpoint, cx);
+            let outcome = match client {
+                Ok(client) => match client.status(&scope).await {
+                    Ok(status) => {
+                        let pages = client.recent(&scope, RECENT_LIMIT).await;
+                        Ok((status, pages))
+                    }
+                    Err(error) => Err(error.to_string()),
+                },
+                Err(error) => Err(error),
+            };
+            this.update(cx, |this, cx| {
+                this.scope = Some(scope);
+                this.loading = false;
+                match outcome {
+                    Ok((status, Ok(pages))) => {
+                        this.status = Some(status);
+                        this.pages = pages;
+                        this.error = None;
+                    }
+                    Ok((status, Err(error))) => {
+                        this.status = Some(status);
+                        this.pages.clear();
+                        this.error = Some(error.to_string());
+                    }
+                    Err(error) => {
+                        this.status = None;
+                        this.pages.clear();
+                        this.error = Some(error);
+                    }
+                }
+                cx.notify();
+            })
+            .log_err();
+        }));
+    }
+
+    /// Asks "Forget <path>?" and deletes the page in the scope shown, only on Forget (R6.4,
+    /// R6.5, R6.10), then reloads the list.
+    pub(crate) fn forget(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
+        if self.forgetting.is_some() {
+            return;
+        }
+        let Some(scope) = self.scope.clone() else {
+            return;
+        };
+        let answer = confirm::confirm_forget(path, window, cx);
+        let path = path.to_string();
+        self.forgetting = Some(path.clone());
+        self.forget_error = None;
+        cx.notify();
+        self.forget_task = Some(cx.spawn(async move |this, cx| {
+            let confirmed = answer.await;
+            // Memory may have been turned off while the prompt was open.
+            let memory = cx.update(|cx| AgentMemorySettings::get_global(cx).clone());
+            let outcome = if confirmed && memory.enabled {
+                match client_for(&this, &memory.endpoint, cx) {
+                    Ok(client) => Some(
+                        client
+                            .delete(&scope, &path)
+                            .await
+                            .map_err(|error| error.to_string()),
+                    ),
+                    Err(error) => Some(Err(error)),
+                }
+            } else {
+                None
+            };
+            this.update(cx, |this, cx| {
+                this.forgetting = None;
+                match outcome {
+                    Some(Ok(())) => this.refresh(cx),
+                    Some(Err(error)) => {
+                        this.forget_error = Some(format!("Could not forget {path}: {error}"));
+                    }
+                    None => {}
+                }
+                cx.notify();
+            })
+            .log_err();
+        }));
+    }
+
+    /// What the section shows about the server and the scope (R6.1), once ai-memory answered.
+    pub(crate) fn status_line(&self) -> Option<String> {
+        self.status.as_ref().map(|status| {
+            format!(
+                "ai-memory {} at {} · {}: {} pages, {} sessions",
+                status.server_version,
+                display_endpoint(&status.endpoint),
+                status.scope,
+                status.pages,
+                status.sessions
+            )
+        })
+    }
+
+    /// The R6.6/R6.8 text of the last load, shown in place.
+    pub(crate) fn error_line(&self) -> Option<String> {
+        self.error.clone()
+    }
+
+    pub(crate) fn pages(&self) -> &[RecentPage] {
+        &self.pages
+    }
+
+    /// The R7.3 note, beside the switch.
+    pub(crate) fn endpoint_note(&self) -> Option<String> {
+        memory_endpoint_note(&self.memory)
+    }
+
+    /// Drops the client and every call in flight, so nothing reaches ai-memory while off (R6.2).
+    fn turn_off(&mut self) {
+        self.client = None;
+        self.load_task = None;
+        self.forget_task = None;
+        self.loading = false;
+        self.forgetting = None;
+        self.scope = None;
+        self.status = None;
+        self.pages.clear();
+        self.error = None;
+        self.forget_error = None;
+    }
+
+    /// Writes `agent_memory.enabled`. Turning memory off asks nothing: unlike the jail, it only
+    /// stops the agents from reading and writing memory, and turning it back on restores them.
+    fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
+        self.write_error = None;
+        let receiver = cx
+            .global::<SettingsStore>()
+            .update_settings_file_with_completion(<dyn Fs>::global(cx), move |content, _| {
+                content.agent_memory.get_or_insert_default().enabled = Some(enabled);
+            });
+        self.write_task = Some(cx.spawn(async move |this, cx| {
+            let error = match receiver.await {
+                Ok(Ok(())) => return,
+                Ok(Err(error)) => error,
+                Err(oneshot::Canceled) => {
+                    anyhow::anyhow!("the settings writer stopped before saving")
+                }
+            };
+            let error = first_line(&error);
+            log::error!(
+                "agent_memory event=settings_write_failed key=\"agent_memory.enabled\" error={error:?}"
+            );
+            this.update(cx, |this, cx| {
+                this.write_error = Some(format!("Could not save the agent memory setting: {error}"));
+                cx.notify();
+            })
+            .log_err();
+        }));
+        cx.notify();
+    }
+
+    /// `control`, wrapped by the section's [`RevealOnFocus`] under `key`.
+    fn reveal(
+        &mut self,
+        key: impl Into<SharedString>,
+        control: impl IntoElement,
+        cx: &mut App,
+    ) -> AnyElement {
+        let scroll_handle = self.scroll_handle.clone();
+        self.controls.wrap(key, &scroll_handle, control, cx)
+    }
+
+    fn render_body(&mut self, cx: &mut Context<Self>) -> AnyElement {
+        if !self.memory.enabled {
+            return muted_label(MEMORY_OFF).into_any_element();
+        }
+        if self.root.is_none() {
+            return muted_label(MEMORY_NO_PROJECT).into_any_element();
+        }
+        let scope_line = self.scope.as_ref().map(|scope| {
+            let source = match scope.resolved_by {
+                ResolvedBy::Marker => "from .ai-memory.toml",
+                ResolvedBy::Upstream => "from the upstream remote",
+                ResolvedBy::Origin => "from the origin remote",
+                ResolvedBy::Folder => "from the folder name; add .ai-memory.toml to choose another",
+            };
+            format!("Scope: {scope} ({source})")
+        });
+        let state_line = match (self.loading, self.status_line()) {
+            (_, Some(status)) => Some(status),
+            (true, None) => Some("Asking ai-memory…".to_string()),
+            (false, None) => None,
+        };
+        let mut rows = Vec::with_capacity(self.pages.len());
+        for (index, page) in self.pages.clone().into_iter().enumerate() {
+            let forget_label = format!("Forget {}", page.path);
+            let forget_id = format!("agent-memory-forget-{index}");
+            let forget = IconButton::new(SharedString::from(forget_id.clone()), IconName::Trash)
+                .icon_size(IconSize::Small)
+                .icon_color(Color::Muted)
+                .tab_index(0_isize)
+                .disabled(self.forgetting.is_some())
+                .aria_label(forget_label.clone())
+                .tooltip(Tooltip::text(forget_label))
+                .on_click(cx.listener({
+                    let path = page.path.clone();
+                    move |this, _, window, cx| this.forget(&path, window, cx)
+                }));
+            let forget = self.reveal(forget_id, forget, cx);
+            rows.push(
+                h_flex()
+                    .gap_2()
+                    .justify_between()
+                    .child(
+                        v_flex()
+                            .min_w_0()
+                            .child(Label::new(page.title).size(LabelSize::Small))
+                            .child(
+                                Label::new(page.path)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                                    .buffer_font(cx),
+                            ),
+                    )
+                    .child(forget),
+            );
+        }
+        let refresh = Button::new("agent-memory-refresh", "Refresh")
+            .label_size(LabelSize::Small)
+            .tab_index(0_isize)
+            .tooltip(Tooltip::text("Ask ai-memory again"))
+            .on_click(cx.listener(|this, _, _, cx| this.refresh(cx)));
+        let refresh = self.reveal("agent-memory-refresh", refresh, cx);
+        let has_pages = !self.pages().is_empty();
+        let error = self.error_line();
+        v_flex()
+            .gap_2()
+            .when_some(state_line, |this, line| {
+                this.child(Label::new(line).size(LabelSize::Small).buffer_font(cx))
+            })
+            .when_some(scope_line, |this, line| this.child(muted_label(line)))
+            .when_some(error.clone(), |this, error| {
+                this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
+            })
+            .child(
+                h_flex()
+                    .justify_between()
+                    .child(Label::new("Recent Pages"))
+                    .child(refresh),
+            )
+            .child(
+                v_flex()
+                    .gap_1p5()
+                    .children(rows)
+                    .when(!has_pages && !self.loading && error.is_none(), |this| {
+                        this.child(muted_label("No pages yet."))
+                    }),
+            )
+            .when_some(self.forget_error.clone(), |this, error| {
+                this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
+            })
+            .into_any_element()
+    }
+}
+
+/// The client for `endpoint`: the one already built, or a new one, built here because it needs
+/// the async context. An unparsable endpoint is never echoed: it may carry a token.
+fn client_for(
+    this: &WeakEntity<AgentMemorySection>,
+    endpoint: &str,
+    cx: &mut AsyncApp,
+) -> Result<Arc<MemoryClient>, String> {
+    let cached = this
+        .read_with(cx, |this, _| {
+            this.client
+                .as_ref()
+                .filter(|(built_for, _)| built_for == endpoint)
+                .map(|(_, client)| client.clone())
+        })
+        .map_err(|error| error.to_string())?;
+    if let Some(client) = cached {
+        return Ok(client);
+    }
+    let url = url::Url::parse(endpoint).map_err(|_| MEMORY_INVALID_ENDPOINT.to_string())?;
+    let http_client = cx.update(|cx| cx.http_client());
+    let auth_token = std::env::var("AI_MEMORY_AUTH_TOKEN").ok();
+    let client = Arc::new(
+        MemoryClient::new(url, auth_token, http_client, cx).map_err(|error| first_line(&error))?,
+    );
+    this.update(cx, |this, _| {
+        this.client = Some((endpoint.to_string(), client.clone()));
+    })
+    .map_err(|error| error.to_string())?;
+    Ok(client)
+}
+
+fn muted_label(text: impl Into<SharedString>) -> Label {
+    Label::new(text).size(LabelSize::Small).color(Color::Muted)
+}
+
+impl Render for AgentMemorySection {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let body = self.render_body(cx);
+        let memory_switch = SwitchField::new(
+            "agent-memory-enabled",
+            Some("Let the agents use ai-memory"),
+            Some(
+                "The Claude agents read and write this project's ai-memory pages. Turning it off asks nothing: it is not the jail."
+                    .into(),
+            ),
+            self.memory.enabled,
+            cx.listener(|this, toggle: &ToggleState, _, cx| {
+                this.set_enabled(*toggle == ToggleState::Selected, cx)
+            }),
+        )
+        .tab_index(0);
+        let memory_switch = self.reveal("agent-memory-enabled", memory_switch, cx);
+        v_flex()
+            .gap_4()
+            .child(SettingsSectionHeader::new("Memory").no_padding(true))
+            .child(memory_switch)
+            .child(muted_label(FORWARD_PORT_EXPOSURE_NOTE))
+            .when_some(self.endpoint_note(), |this, note| {
+                this.child(
+                    Label::new(note)
+                        .size(LabelSize::Small)
+                        .color(Color::Warning),
+                )
+            })
+            .when_some(self.write_error.clone(), |this, error| {
+                this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
+            })
+            .child(body)
+    }
+}
+
+/// The section of `root`, kept across frames of whichever page renders it.
+fn memory_section(
+    root: Option<PathBuf>,
+    scroll_handle: &ScrollHandle,
+    window: &mut Window,
+    cx: &mut App,
+) -> Entity<AgentMemorySection> {
+    let key = format!(
+        "agent-memory-section-{}",
+        root.as_deref()
+            .map(|root| root.to_string_lossy())
+            .unwrap_or_default()
+    );
+    let section = window.use_keyed_state(SharedString::from(key), cx, move |window, cx| {
+        AgentMemorySection::new(root, window, cx)
+    });
+    section.update(cx, |section, _| {
+        section.scroll_handle = scroll_handle.clone();
+    });
+    section
+}
+
+/// The root of the settings window's active local project.
+fn active_root(settings_window: &SettingsWindow, cx: &App) -> Option<PathBuf> {
+    settings_window
+        .active_project(cx)
+        .filter(|project| project.read(cx).is_local())
+        .and_then(|project| launch::project_root(&project, cx))
+}
+
+/// The "Agent Memory (ai-memory)" sub-page: the Memory section alone, so that
+/// `OpenSettingsAt { path: "agent_memory" }` lands on it (a sub-page cannot scroll to a section).
+pub(crate) fn render_agent_memory_page(
+    settings_window: &SettingsWindow,
+    scroll_handle: &ScrollHandle,
+    window: &mut Window,
+    cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+    let section = memory_section(active_root(settings_window, cx), scroll_handle, window, cx);
+    v_flex()
+        .id("agent-memory-page")
+        .debug_selector(|| "agent-memory-page".into())
+        .size_full()
+        .pt_2p5()
+        .px_8()
+        .pb_16()
+        .overflow_y_scroll()
+        .track_scroll(scroll_handle)
+        .child(section)
+        .into_any_element()
+}
+
+#[cfg(test)]
+#[path = "agent_jail_page_tests.rs"]
+mod agent_jail_page_tests;
diff --git a/crates/settings_ui/src/pages/agent_jail_page_tests.rs b/crates/settings_ui/src/pages/agent_jail_page_tests.rs
new file mode 100644
index 0000000..8fac6d8
--- /dev/null
+++ b/crates/settings_ui/src/pages/agent_jail_page_tests.rs
@@ -0,0 +1,1059 @@
+// Story 043, tasks 5.2 and 7.1 — the "Agent Sandbox (ai-jail)" sub-page and its Memory section
+// (R3.1, R3.3, R3.4, R3.5, R3.6, R4.4, R6.1, R6.2, R6.3, R6.4, R6.5, R6.6, R6.8, R7.3). Wired
+// from `src/pages/agent_jail_page.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "agent_jail_page_tests.rs"]
+//     mod agent_jail_page_tests;
+//
+// Test paths are `…::agent_jail_page_tests::page::…` and `…::agent_jail_page_tests::agent_memory_section::…`,
+// so the Validation filters `agent_jail_page` (5.2) and `agent_memory_section` (7.1) select them.
+//
+// Contract (design.md §Settings sub-page, §agent_sandbox::memory). The registration is asserted
+// on the real `page_data::settings_data`. GPUI offers no text query on rendered elements, so the
+// page's text-bearing decisions are asserted on the functions the render calls. Assumed seams —
+// adjust the `use`/call only, keep the assertions:
+//   - `super::banner_text(installed: Option<&semver::Version>) -> Option<String>` — `None` means
+//     ai-jail was not found (R3.6), an outdated version gives the R3.5 warning, a safe one none.
+//   - `super::project_notice(root: Option<&Path>) -> Option<&'static str>` — R3.3.
+//   - `super::add_entry(kind: EntryKind, root: &Path, raw: &str, cx: &mut App) -> Task<Result<(), String>>`
+//     and `super::remove_entry(kind, root, raw, cx) -> Task<Result<(), String>>`, with
+//     `EntryKind::{RwMap, AllowHost}`: validate (task 2.1), write `projects[root]` through
+//     `update_settings_file`, or return the rule text and write nothing (R3.4, R4.4).
+//   - `super::AgentMemorySection` (an entity): `new(root: Option<PathBuf>, window, cx)` using the
+//     user `agent_memory` settings and `cx.http_client()`; `refresh(&mut self, cx)`;
+//     `forget(&mut self, path: &str, window, cx)`; read-outs `status_line() -> Option<String>`,
+//     `error_line() -> Option<String>`, `pages() -> &[RecentPage]`, `endpoint_note() -> Option<String>`.
+//
+// Hostile halves first:
+//   - entries, wrongly WRITE: a refused entry leaves the settings file byte-identical; wrongly
+//     ASK: adding and removing never prompt.
+//   - banner, wrongly SILENT: missing → install hint; outdated → both advisories; wrongly NOISY:
+//     a safe version (including 10.0.0) shows none.
+//   - memory, wrongly CALL: memory off makes zero requests; a cancelled Forget sends no delete;
+//     an error is never retried in the background.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::Duration;
+
+use fs::{FakeFs, Fs};
+use futures::AsyncReadExt as _;
+use gpui::{TestAppContext, UpdateGlobal as _};
+use gpui::{VisualContext as _, VisualTestContext};
+use http_client::{AsyncBody, HttpClient, Response};
+use parking_lot::Mutex;
+use serde_json::{Value, json};
+use settings::SettingsStore;
+
+use super::AgentMemorySection;
+use super::{EntryKind, add_entry, banner_text, project_notice, remove_entry};
+use crate::SettingsPageItem;
+
+const ROOT: &str = "/w/app";
+
+async fn init(user: Value, cx: &mut TestAppContext) -> Arc<FakeFs> {
+    let fs = FakeFs::new(cx.executor());
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        theme_settings::init(theme::LoadThemes::JustBase, cx);
+        <dyn Fs>::set_global(fs.clone(), cx);
+        agent_sandbox::init(cx);
+    });
+    fs.create_dir(paths::config_dir())
+        .await
+        .expect("the config dir");
+    fs.insert_file(paths::settings_file(), user.to_string().into_bytes())
+        .await;
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store
+                .set_user_settings(&user.to_string(), cx)
+                .result()
+                .expect("settings");
+        })
+    });
+    cx.run_until_parked();
+    fs
+}
+
+async fn file_text(fs: &Arc<FakeFs>) -> String {
+    fs.load(paths::settings_file())
+        .await
+        .expect("settings file")
+}
+
+mod page {
+    use super::*;
+
+    // --- R3.1: the link beside Zed's own "Sandbox" ------------------------------------------
+
+    #[gpui::test]
+    fn agent_jail_page_link_follows_sandbox_in_the_ai_page(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let store = SettingsStore::test(cx);
+            cx.set_global(store);
+            let app_state = workspace::AppState::test(cx);
+            workspace::AppState::set_global(app_state, cx);
+            let pages = crate::page_data::settings_data(cx);
+            let links: Vec<(String, Option<&'static str>, &'static [&'static str], bool)> = pages
+                .iter()
+                .flat_map(|page| page.items.iter())
+                .filter_map(|item| match item {
+                    SettingsPageItem::SubPageLink(link) => Some((
+                        link.title.to_string(),
+                        link.json_path,
+                        link.search_aliases,
+                        link.files == crate::USER,
+                    )),
+                    _ => None,
+                })
+                .collect();
+            let sandbox = links
+                .iter()
+                .position(|(title, ..)| title == "Sandbox")
+                .expect("Zed's own Sandbox link");
+            let (title, json_path, aliases, user_only) = &links[sandbox + 1];
+            assert_eq!(title, "Agent Sandbox (ai-jail)", "right after Sandbox");
+            assert_eq!(*json_path, Some("agent_sandbox"));
+            assert!(user_only, "user file only (R2.1)");
+            for alias in ["ai-jail", "memory", "ai-memory"] {
+                assert!(
+                    aliases.contains(&alias),
+                    "search alias {alias}: {aliases:?}"
+                );
+            }
+        });
+    }
+
+    // --- R3.5/R3.6 banners ----------------------------------------------------------------------
+
+    #[test]
+    fn agent_jail_page_banner_for_missing_and_outdated_only() {
+        let missing = banner_text(None).expect("a banner when ai-jail is not found");
+        assert!(missing.contains("ai-jail is not installed"), "{missing}");
+        assert!(missing.contains("emerge sys-apps/ai-jail"), "{missing}");
+        assert!(
+            missing.contains("app-editors/zeo"),
+            "the USE ai-jail hint: {missing}"
+        );
+
+        let old = banner_text(Some(&semver::Version::new(2, 3, 9))).expect("outdated warns");
+        for needle in ["GHSA-frgp-q3qc-g78p", "GHSA-w976-gw52-hvx2", "2.4.0"] {
+            assert!(old.contains(needle), "{needle} in {old}");
+        }
+        for safe in [(2, 4, 0), (2, 8, 1), (10, 0, 0)] {
+            let version = semver::Version::new(safe.0, safe.1, safe.2);
+            assert_eq!(
+                banner_text(Some(&version)),
+                None,
+                "{version} needs no banner"
+            );
+        }
+    }
+
+    #[test]
+    fn agent_jail_page_no_project_notice() {
+        assert_eq!(
+            project_notice(None),
+            Some("Open a local project to configure it.")
+        );
+        assert_eq!(project_notice(Some(Path::new(ROOT))), None);
+    }
+
+    // --- R3.4/R4.4 entries ------------------------------------------------------------------------
+
+    #[gpui::test]
+    async fn agent_jail_page_refused_entry_writes_nothing_and_names_the_rule(
+        cx: &mut TestAppContext,
+    ) {
+        let fs = init(
+            json!({ "agent_sandbox": { "projects": { ROOT: { "enabled": true } } } }),
+            cx,
+        )
+        .await;
+        let before = file_text(&fs).await;
+        for (kind, raw) in [
+            (EntryKind::AllowHost, "https://registry.npmjs.org"),
+            (EntryKind::AllowHost, "127.0.0.1"),
+            (EntryKind::RwMap, "relative/path"),
+            (EntryKind::RwMap, "/tmp/a:b"),
+        ] {
+            let task = cx.update(|cx| add_entry(kind, Path::new(ROOT), raw, cx));
+            cx.run_until_parked();
+            let error = task.await.expect_err(raw);
+            assert!(
+                error.contains(raw),
+                "the rule text names the entry: {error}"
+            );
+            assert!(!cx.has_pending_prompt(), "never a prompt");
+        }
+        assert_eq!(
+            file_text(&fs).await,
+            before,
+            "the settings file is unchanged"
+        );
+    }
+
+    #[gpui::test]
+    async fn agent_jail_page_valid_entries_save_without_asking(cx: &mut TestAppContext) {
+        let fs = init(
+            json!({ "agent_sandbox": { "projects": { ROOT: { "enabled": true } } } }),
+            cx,
+        )
+        .await;
+        let task = cx.update(|cx| {
+            add_entry(
+                EntryKind::AllowHost,
+                Path::new(ROOT),
+                "registry.npmjs.org",
+                cx,
+            )
+        });
+        cx.run_until_parked();
+        assert!(!cx.has_pending_prompt());
+        task.await.expect("valid host");
+        let written: Value = serde_json_lenient::from_str(&file_text(&fs).await).expect("json");
+        assert_eq!(
+            written["agent_sandbox"]["projects"][ROOT]["allow_hosts"],
+            json!(["registry.npmjs.org"])
+        );
+        assert_eq!(
+            written["agent_sandbox"]["projects"][ROOT]["enabled"],
+            json!(true),
+            "switch kept"
+        );
+
+        let task = cx.update(|cx| {
+            remove_entry(
+                EntryKind::AllowHost,
+                Path::new(ROOT),
+                "registry.npmjs.org",
+                cx,
+            )
+        });
+        cx.run_until_parked();
+        assert!(!cx.has_pending_prompt());
+        task.await.expect("remove");
+        let written: Value = serde_json_lenient::from_str(&file_text(&fs).await).expect("json");
+        let hosts = &written["agent_sandbox"]["projects"][ROOT]["allow_hosts"];
+        assert!(hosts.is_null() || hosts == &json!([]), "{hosts}");
+    }
+
+    // --- Authored at Run (v2 and the tech-review fixes) ------------------------------------------
+
+    use std::ffi::OsStr;
+    use std::os::unix::ffi::OsStrExt as _;
+
+    use agent_sandbox::launch::MEMORY_PORT_UNSUPPORTED;
+    use agent_sandbox::{AgentMemorySettings, AgentSandboxProjectSettings, AgentSandboxSettings};
+
+    use super::super::{
+        EntryEnv, add_entry_in, ai_jail_file_notice, forward_port_notice, memory_endpoint_note,
+        page_key, preview_command, preview_off_notice,
+    };
+
+    const LOOPBACK: &str = "http://127.0.0.1:49374/mcp";
+    const INVALID_ENDPOINT_NOTE: &str =
+        "No port is forwarded into the jail: the ai-memory endpoint is not a valid URL.";
+
+    fn memory(enabled: bool, endpoint: &str) -> AgentMemorySettings {
+        AgentMemorySettings {
+            enabled,
+            endpoint: endpoint.to_string(),
+        }
+    }
+
+    fn path_text(path: &Path) -> String {
+        path.to_str().expect("a UTF-8 temp path").to_string()
+    }
+
+    // R3.9: the notice names a file at the root, never a folder of that name.
+    #[gpui::test]
+    async fn agent_jail_page_ai_jail_notice_only_when_the_file_exists(cx: &mut TestAppContext) {
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/w", json!({ "app": {}, "other": { ".ai-jail": {} } }))
+            .await;
+        assert_eq!(
+            ai_jail_file_notice(fs.as_ref(), Path::new(ROOT)).await,
+            None,
+            "no .ai-jail, no notice"
+        );
+        assert_eq!(
+            ai_jail_file_notice(fs.as_ref(), Path::new("/w/other")).await,
+            None,
+            "a folder named .ai-jail is not the file ai-jail reads"
+        );
+        fs.insert_file("/w/app/.ai-jail", b"rw_maps = []\n".to_vec())
+            .await;
+        let notice = ai_jail_file_notice(fs.as_ref(), Path::new(ROOT))
+            .await
+            .expect("the R3.9 notice");
+        assert!(notice.contains(".ai-jail"), "{notice}");
+        assert!(notice.contains("only narrow"), "{notice}");
+    }
+
+    // R3.10: only memory on, a loopback endpoint and an ai-jail without --forward-port.
+    #[test]
+    fn agent_jail_page_forward_port_notice_only_with_memory_on_and_the_flag_absent() {
+        assert_eq!(
+            forward_port_notice(&memory(true, LOOPBACK), false),
+            Some(MEMORY_PORT_UNSUPPORTED)
+        );
+        assert_eq!(
+            forward_port_notice(&memory(true, "http://localhost:7000/mcp"), false),
+            Some(MEMORY_PORT_UNSUPPORTED)
+        );
+        assert_eq!(
+            forward_port_notice(&memory(true, LOOPBACK), true),
+            None,
+            "the flag is offered"
+        );
+        assert_eq!(
+            forward_port_notice(&memory(false, LOOPBACK), false),
+            None,
+            "memory off"
+        );
+        assert_eq!(
+            forward_port_notice(&memory(true, "http://mem.example.com:8080/mcp"), false),
+            None,
+            "a remote endpoint is never forwarded (R7.3 says so instead)"
+        );
+        assert_eq!(forward_port_notice(&memory(true, "not a url"), false), None);
+    }
+
+    // R7.3: a non-loopback endpoint gets no port and the page says to allow its host.
+    #[test]
+    fn agent_jail_page_non_loopback_memory_says_to_allow_the_host() {
+        let note = memory_endpoint_note(&memory(true, "http://mem.example.com:8080/mcp"))
+            .expect("the R7.3 note");
+        assert!(note.contains("mem.example.com"), "{note}");
+        assert!(note.contains("allowed hosts"), "{note}");
+        assert_eq!(memory_endpoint_note(&memory(true, LOOPBACK)), None);
+        assert_eq!(
+            memory_endpoint_note(&memory(true, "http://[::1]:5000/mcp")),
+            None
+        );
+        assert_eq!(
+            memory_endpoint_note(&memory(false, "http://mem.example.com:8080/mcp")),
+            None,
+            "memory off: nothing to reach"
+        );
+    }
+
+    // Q14: the note shows the host only, never userinfo, query, fragment or unparsable text.
+    #[test]
+    fn agent_jail_page_memory_note_never_shows_the_raw_endpoint() {
+        let note = memory_endpoint_note(&memory(
+            true,
+            "http://alice:s3cret@mem.example.com:8080/mcp?token=t0ken#frag",
+        ))
+        .expect("the R7.3 note");
+        assert!(note.contains("mem.example.com"), "{note}");
+        for leaked in ["alice", "s3cret", "token", "t0ken", "frag", "8080", "/mcp"] {
+            assert!(!note.contains(leaked), "{leaked} leaked into {note}");
+        }
+        for unparsable in ["s3cret-token@mem", "http://exa mple.com:99999/mcp?k=s3cret"] {
+            assert_eq!(
+                memory_endpoint_note(&memory(true, unparsable)).as_deref(),
+                Some(INVALID_ENDPOINT_NOTE),
+                "{unparsable}"
+            );
+        }
+    }
+
+    // R3.7: the preview says when the sandbox is off; when on it is generate's argv.
+    #[test]
+    fn agent_jail_page_preview_says_when_the_sandbox_is_off() {
+        assert_eq!(
+            preview_off_notice(false),
+            Some("Sandbox off: the agent starts without ai-jail.")
+        );
+        assert_eq!(preview_off_notice(true), None);
+
+        let policy = agent_sandbox::ProjectPolicy {
+            enabled: true,
+            rw_maps: vec!["/data/shared".to_string()],
+            allow_hosts: vec!["anthropic.com".to_string()],
+        };
+        let jail = Path::new("/usr/bin/ai-jail");
+        let forwarded = preview_command(
+            &policy,
+            Path::new(ROOT),
+            "/home/u/.claude-jailed",
+            Some(jail),
+            &memory(true, LOOPBACK),
+            true,
+        );
+        for needle in [
+            "/usr/bin/ai-jail --exec",
+            "--rw-map /data/shared",
+            "--allow-host anthropic.com",
+            "--claude-dir /home/u/.claude-jailed",
+            "--map /w/app/.git",
+            "--forward-port 49374",
+            // With memory forwarded the agent runs behind the loopback no_proxy wrapper,
+            // so it is the last word of `-- /bin/sh -c '…' sh claude-agent-acp-plus`.
+            " sh claude-agent-acp-plus",
+        ] {
+            assert!(forwarded.contains(needle), "{needle} in {forwarded}");
+        }
+        assert!(!forwarded.contains("--dry-run"), "{forwarded}");
+        for (memory, supports) in [
+            (memory(true, LOOPBACK), false),
+            (memory(false, LOOPBACK), true),
+            (memory(true, "http://mem.example.com:8080/mcp"), true),
+        ] {
+            let preview = preview_command(
+                &policy,
+                Path::new(ROOT),
+                "/home/u/.claude-jailed",
+                Some(jail),
+                &memory,
+                supports,
+            );
+            assert!(!preview.contains("--forward-port"), "{preview}");
+        }
+    }
+
+    // R7.1: with ai-memory forwarded a start runs the agent through /bin/sh, which keeps loopback
+    // off ai-jail's egress proxy; the preview shows that same argv, and only then.
+    #[test]
+    fn agent_jail_page_preview_keeps_loopback_off_the_proxy_only_when_forwarded() {
+        let policy = agent_sandbox::ProjectPolicy {
+            enabled: true,
+            rw_maps: Vec::new(),
+            allow_hosts: vec!["anthropic.com".to_string()],
+        };
+        let jail = Path::new("/usr/bin/ai-jail");
+        let preview = |memory: &AgentMemorySettings, supports: bool| {
+            preview_command(
+                &policy,
+                Path::new(ROOT),
+                "/home/u/.claude-jailed",
+                Some(jail),
+                memory,
+                supports,
+            )
+        };
+        let forwarded = preview(&memory(true, LOOPBACK), true);
+        for needle in [
+            "--forward-port 49374",
+            "--env ZEO_NO_PROXY",
+            "-- /bin/sh -c ",
+        ] {
+            assert!(forwarded.contains(needle), "{needle} in {forwarded}");
+        }
+        assert!(
+            forwarded.ends_with(" sh claude-agent-acp-plus"),
+            "the agent is exec'd unchanged: {forwarded}"
+        );
+        assert_eq!(
+            forwarded.matches(" -- ").count(),
+            1,
+            "one separator, before /bin/sh: {forwarded}"
+        );
+        for (memory, supports) in [
+            (memory(true, LOOPBACK), false),
+            (memory(false, LOOPBACK), true),
+            (memory(true, "http://mem.example.com:8080/mcp"), true),
+        ] {
+            let preview = preview(&memory, supports);
+            assert!(!preview.contains("ZEO_NO_PROXY"), "{preview}");
+            assert!(!preview.contains("/bin/sh"), "{preview}");
+            assert!(preview.ends_with(" -- claude-agent-acp-plus"), "{preview}");
+        }
+    }
+
+    // R2.4 + launch::check_nesting: the page refuses what a start would refuse.
+    #[gpui::test]
+    async fn agent_jail_page_nested_folder_refused_and_writes_nothing(cx: &mut TestAppContext) {
+        let temp = tempfile::tempdir().expect("tempdir");
+        let base = temp.path().canonicalize().expect("canonical tempdir");
+        let home = base.join("home");
+        let config_dir = home.join(".config").join("zed");
+        for dir in [
+            config_dir.clone(),
+            base.join("proj").join("sub"),
+            base.join("claude").join("inner"),
+            base.join("area").join("shared").join("inner"),
+            base.join("sibling"),
+        ] {
+            std::fs::create_dir_all(&dir).expect("fixture folder");
+        }
+        let root = base.join("proj");
+        let root_key = path_text(&root);
+        let fs = init(
+            json!({ "agent_sandbox": {
+                "claude_config_dir": path_text(&base.join("claude")),
+                "projects": { root_key.clone(): {
+                    "enabled": true,
+                    "rw_maps": [path_text(&base.join("area").join("shared"))],
+                } },
+            } }),
+            cx,
+        )
+        .await;
+        let env = || EntryEnv {
+            home: home.clone(),
+            config_dir: config_dir.clone(),
+        };
+        let before = file_text(&fs).await;
+        for (case, folder) in [
+            ("inside the project root", base.join("proj").join("sub")),
+            ("inside the Claude dir", base.join("claude").join("inner")),
+            (
+                "inside another writable folder",
+                base.join("area").join("shared").join("inner"),
+            ),
+            ("containing another writable folder", base.join("area")),
+        ] {
+            let raw = path_text(&folder);
+            let task = cx.update(|cx| add_entry_in(EntryKind::RwMap, &root, &raw, env(), cx));
+            cx.run_until_parked();
+            let error = task.await.expect_err(case);
+            assert!(error.contains("inside"), "{case}: {error}");
+            assert!(!cx.has_pending_prompt(), "never a prompt");
+        }
+        assert_eq!(
+            file_text(&fs).await,
+            before,
+            "a refused folder writes nothing"
+        );
+
+        let sibling = path_text(&base.join("sibling"));
+        let task = cx.update(|cx| add_entry_in(EntryKind::RwMap, &root, &sibling, env(), cx));
+        cx.run_until_parked();
+        task.await.expect("a sibling folder is accepted");
+        let written: Value = serde_json_lenient::from_str(&file_text(&fs).await).expect("json");
+        let entry = &written["agent_sandbox"]["projects"][root_key.as_str()];
+        assert_eq!(
+            entry["rw_maps"],
+            json!([path_text(&base.join("area").join("shared")), sibling])
+        );
+        assert_eq!(entry["enabled"], json!(true), "switch kept");
+    }
+
+    // Tech review HIGH: an edit through a symlinked root must not create a second key, or the
+    // next start would find a key without `enabled` and run the agent unjailed.
+    #[gpui::test]
+    async fn agent_jail_page_list_edits_keep_the_key_a_symlinked_root_resolves_to(
+        cx: &mut TestAppContext,
+    ) {
+        let temp = tempfile::tempdir().expect("tempdir");
+        let base = temp.path().canonicalize().expect("canonical tempdir");
+        let real = base.join("real");
+        std::fs::create_dir(&real).expect("real root");
+        let link = base.join("link");
+        std::os::unix::fs::symlink(&real, &link).expect("symlinked root");
+        let real_key = path_text(&real);
+        let fs = init(
+            json!({ "agent_sandbox": { "enabled": false, "projects": { real_key.clone(): { "enabled": true } } } }),
+            cx,
+        )
+        .await;
+
+        let task = cx.update(|cx| add_entry(EntryKind::AllowHost, &link, "registry.npmjs.org", cx));
+        cx.run_until_parked();
+        task.await.expect("valid host");
+        let written: Value = serde_json_lenient::from_str(&file_text(&fs).await).expect("json");
+        let projects = written["agent_sandbox"]["projects"]
+            .as_object()
+            .expect("projects");
+        assert_eq!(
+            projects.keys().collect::<Vec<_>>(),
+            vec![&real_key],
+            "no second key"
+        );
+        assert_eq!(projects[&real_key]["enabled"], json!(true), "jail stays on");
+        assert_eq!(
+            projects[&real_key]["allow_hosts"],
+            json!(["registry.npmjs.org"])
+        );
+
+        let text = file_text(&fs).await;
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store
+                    .set_user_settings(&text, cx)
+                    .result()
+                    .expect("settings");
+            })
+        });
+        let task =
+            cx.update(|cx| remove_entry(EntryKind::AllowHost, &link, "registry.npmjs.org", cx));
+        cx.run_until_parked();
+        task.await.expect("remove");
+        let written: Value = serde_json_lenient::from_str(&file_text(&fs).await).expect("json");
+        let projects = written["agent_sandbox"]["projects"]
+            .as_object()
+            .expect("projects kept");
+        assert_eq!(projects.keys().collect::<Vec<_>>(), vec![&real_key]);
+        assert_eq!(projects[&real_key]["enabled"], json!(true), "jail stays on");
+        assert_eq!(
+            projects[&real_key]["allow_hosts"],
+            json!([]),
+            "an emptied list stays"
+        );
+    }
+
+    // Tech review MEDIUM: the page shows the entry a start would use.
+    #[test]
+    fn agent_jail_page_reads_the_resolved_key() {
+        let mut sandbox = AgentSandboxSettings {
+            enabled: false,
+            ai_jail_path: None,
+            claude_config_dir: "~/.claude-jailed".to_string(),
+            rw_maps: Vec::new(),
+            allow_hosts: Vec::new(),
+            projects: Default::default(),
+        };
+        sandbox.projects.insert(
+            "/srv/real".to_string(),
+            AgentSandboxProjectSettings {
+                enabled: Some(true),
+                rw_maps: Vec::new(),
+                allow_hosts: vec!["registry.npmjs.org".to_string()],
+            },
+        );
+        let link = Path::new("/home/u/link");
+        assert_eq!(
+            page_key(&sandbox, link, None),
+            None,
+            "before the background resolution lands"
+        );
+        let key = page_key(&sandbox, link, Some("/srv/real"));
+        assert_eq!(key, Some("/srv/real"));
+        let policy = sandbox.policy_for_key(key);
+        assert!(policy.enabled, "the switch shows the resolved entry");
+        assert_eq!(policy.allow_hosts, vec!["registry.npmjs.org".to_string()]);
+        assert_eq!(
+            page_key(&sandbox, Path::new("/srv/real/"), None),
+            Some("/srv/real"),
+            "a lexical match needs no resolution"
+        );
+        assert_eq!(
+            page_key(&sandbox, link, Some("/srv/gone")),
+            None,
+            "a resolved key the settings no longer hold"
+        );
+    }
+
+    // A non-UTF-8 root has no key that could name it: refused, nothing written.
+    #[gpui::test]
+    async fn agent_jail_page_non_utf8_root_is_refused(cx: &mut TestAppContext) {
+        let fs = init(json!({ "agent_sandbox": { "enabled": true } }), cx).await;
+        let before = file_text(&fs).await;
+        let root = PathBuf::from(OsStr::from_bytes(b"/w/\xffapp"));
+        let expected = agent_sandbox::confirm::non_utf8_root_message(&root);
+        let task = cx.update(|cx| add_entry(EntryKind::AllowHost, &root, "registry.npmjs.org", cx));
+        cx.run_until_parked();
+        assert_eq!(task.await, Err(expected.clone()));
+        let task =
+            cx.update(|cx| remove_entry(EntryKind::AllowHost, &root, "registry.npmjs.org", cx));
+        cx.run_until_parked();
+        assert_eq!(task.await, Err(expected));
+        assert_eq!(file_text(&fs).await, before);
+    }
+
+    // --- Q16: Tab reaches every control, and the page scrolls to the focused one ---------------
+
+    /// The controls Tab walks through, in page order, with the selectors their bounds are
+    /// recorded under: the switches, the list inputs, Copy Command, the Memory switch, Refresh and
+    /// one Forget per recent page of the fake ai-memory.
+    const TAB_ORDER: [&str; 9] = [
+        "agent-jail-project-enabled",
+        "agent-jail-rw-map-new",
+        "agent-jail-allow-host-new",
+        "agent-jail-copy-command",
+        "agent-jail-default-enabled",
+        "agent-memory-enabled",
+        "agent-memory-refresh",
+        "agent-memory-forget-0",
+        "agent-memory-forget-1",
+    ];
+
+    #[gpui::test]
+    async fn agent_jail_page_tab_reaches_every_control_in_view(cx: &mut TestAppContext) {
+        use gpui::{AppContext as _, KeyBinding, px};
+        use project::Project;
+        use workspace::{MultiWorkspace, Workspace};
+
+        cx.update(|cx| {
+            crate::test::register_settings(cx);
+            agent_sandbox::init(cx);
+            // Zed binds Tab to `menu::SelectNext`; the settings window turns it into `focus_next`.
+            cx.bind_keys([KeyBinding::new("tab", menu::SelectNext, None)]);
+        });
+        let app_state = cx.update(|cx| {
+            let app_state = workspace::AppState::test(cx);
+            workspace::AppState::set_global(app_state.clone(), cx);
+            app_state
+        });
+        let user = json!({
+            "agent_sandbox": {
+                "ai_jail_path": "/nonexistent/ai-jail",
+                "projects": { "/project": { "enabled": true } }
+            },
+            "agent_memory": { "enabled": true }
+        });
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store
+                    .set_user_settings(&user.to_string(), cx)
+                    .result()
+                    .expect("settings");
+            })
+        });
+        let _calls =
+            super::agent_memory_section::fake_memory(super::agent_memory_section::Mode::Ok, cx);
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree("/project", json!({ "main.rs": "fn main() {}" }))
+            .await;
+        let project = cx.update(|cx| {
+            Project::local(
+                app_state.client.clone(),
+                app_state.node_runtime.clone(),
+                app_state.user_store.clone(),
+                app_state.languages.clone(),
+                app_state.fs.clone(),
+                None,
+                project::LocalProjectFlags::default(),
+                cx,
+            )
+        });
+        project
+            .update(cx, |project, cx| {
+                project.find_or_create_worktree("/project", true, cx)
+            })
+            .await
+            .expect("the project worktree");
+        let (_multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            let workspace = cx.new(|cx| {
+                Workspace::new(
+                    Default::default(),
+                    project.clone(),
+                    app_state.clone(),
+                    window,
+                    cx,
+                )
+            });
+            MultiWorkspace::new(workspace, window, cx)
+        });
+        let workspace_handle = cx
+            .window_handle()
+            .downcast::<MultiWorkspace>()
+            .expect("a multi-workspace window");
+        cx.run_until_parked();
+
+        let (settings_window, cx) = cx.add_window_view(|window, cx| {
+            crate::SettingsWindow::new(Some(workspace_handle), window, cx)
+        });
+        // The size of the settings window as it opens, at which the proof saw the gap.
+        cx.simulate_resize(gpui::size(px(900.), px(750.)));
+        cx.run_until_parked();
+        settings_window.update_in(cx, |settings_window, window, cx| {
+            settings_window.push_dynamic_sub_page(
+                "Agent Sandbox (ai-jail)",
+                "Agent Configuration",
+                Some("agent_sandbox"),
+                false,
+                super::super::render_agent_jail_page,
+                window,
+                cx,
+            );
+        });
+        let draw = |cx: &mut VisualTestContext| {
+            for _ in 0..3 {
+                cx.update(|window, cx| {
+                    window.simulate_next_frame(cx);
+                });
+                cx.run_until_parked();
+            }
+        };
+        draw(cx);
+        let viewport = cx
+            .debug_bounds("agent-jail-page")
+            .expect("the page's scroll container");
+        assert!(
+            viewport.bottom() <= px(750.),
+            "the page is clipped by the window: {viewport:?}"
+        );
+        let last = cx
+            .debug_bounds("agent-memory-forget-1")
+            .expect("the last Forget button");
+        assert!(
+            last.top() > viewport.bottom(),
+            "the fixture must start with controls below the fold: {last:?} vs {viewport:?}"
+        );
+
+        let mut reached: Vec<&'static str> = Vec::new();
+        for _ in 0..(TAB_ORDER.len() * 2 + 4) {
+            if let Some(control) = super::super::focused_control()
+                .and_then(|focused| TAB_ORDER.into_iter().find(|name| *name == focused.as_ref()))
+            {
+                let bounds = cx.debug_bounds(control).expect("the focused control");
+                assert!(
+                    bounds.top() >= viewport.top() && bounds.bottom() <= viewport.bottom(),
+                    "{control} is focused out of view: {bounds:?} outside {viewport:?}"
+                );
+                if !reached.contains(&control) {
+                    reached.push(control);
+                }
+            }
+            cx.simulate_keystrokes("tab");
+            draw(cx);
+        }
+        assert_eq!(
+            reached,
+            TAB_ORDER.to_vec(),
+            "Tab reaches every control once, in page order"
+        );
+    }
+}
+
+mod agent_memory_section {
+    use super::*;
+
+    const STATUS: &str = r#"{"counts":{"pages_latest":69,"pages_all":169,"sessions":118,"observations":1,"evidence_rows":0},"scope":{"workspace":"default","project":"app","resolved_by":"explicit"}}"#;
+    const RECENT: &str = r#"{"hits":[{"id":"1","path":"sessions/a.md","title":"Session a","snippet":"s","rank":2.0},{"id":"2","path":"gotchas/b.md","title":"Gotcha b","snippet":"s","rank":1.0}]}"#;
+
+    #[derive(Clone, Copy)]
+    pub(super) enum Mode {
+        Ok,
+        Refused,
+        UnknownProject,
+    }
+
+    /// Installs a fake ai-memory as the app's HTTP client; returns the tool names called.
+    pub(super) fn fake_memory(
+        mode: Mode,
+        cx: &mut TestAppContext,
+    ) -> Arc<Mutex<Vec<(String, Value)>>> {
+        let calls: Arc<Mutex<Vec<(String, Value)>>> = Arc::default();
+        let client = http_client::FakeHttpClient::create({
+            let calls = calls.clone();
+            move |request| {
+                let calls = calls.clone();
+                async move {
+                    if matches!(mode, Mode::Refused) {
+                        anyhow::bail!("Connection refused (os error 111)");
+                    }
+                    let mut text = String::new();
+                    request.into_body().read_to_string(&mut text).await?;
+                    let message: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
+                    let id = message["id"].clone();
+                    let body = match message["method"].as_str().unwrap_or_default() {
+                        "initialize" => json!({ "jsonrpc": "2.0", "id": id, "result": {
+                            "protocolVersion": message["params"]["protocolVersion"],
+                            "capabilities": { "tools": {} },
+                            "serverInfo": { "name": "ai-memory", "version": "2.6.2" } } }),
+                        "tools/call" => {
+                            let tool = message["params"]["name"]
+                                .as_str()
+                                .unwrap_or_default()
+                                .to_string();
+                            calls
+                                .lock()
+                                .push((tool.clone(), message["params"]["arguments"].clone()));
+                            match (mode, tool.as_str()) {
+                                (Mode::UnknownProject, _) => {
+                                    json!({ "jsonrpc": "2.0", "id": id, "error": {
+                                    "code": -32602, "message": "project 'app' not found in workspace 'default'" } })
+                                }
+                                (_, "memory_status") => {
+                                    json!({ "jsonrpc": "2.0", "id": id, "result": {
+                                    "content": [{ "type": "text", "text": STATUS }] } })
+                                }
+                                (_, "memory_recent") => {
+                                    json!({ "jsonrpc": "2.0", "id": id, "result": {
+                                    "content": [{ "type": "text", "text": RECENT }] } })
+                                }
+                                _ => json!({ "jsonrpc": "2.0", "id": id, "result": {
+                                    "content": [{ "type": "text", "text": "{\"deleted\":true}" }] } }),
+                            }
+                        }
+                        _ => {
+                            return Ok(Response::builder()
+                                .status(202)
+                                .body(AsyncBody::default())
+                                .expect("202"));
+                        }
+                    };
+                    Ok(Response::builder()
+                        .status(200)
+                        .header("Content-Type", "application/json")
+                        .body(AsyncBody::from(body.to_string().into_bytes()))
+                        .expect("200"))
+                }
+            }
+        });
+        cx.update(|cx| cx.set_http_client(client as Arc<dyn HttpClient>));
+        calls
+    }
+
+    fn tools(calls: &Arc<Mutex<Vec<(String, Value)>>>) -> Vec<String> {
+        calls.lock().iter().map(|(tool, _)| tool.clone()).collect()
+    }
+
+    fn open_section(
+        cx: &mut TestAppContext,
+    ) -> (gpui::Entity<AgentMemorySection>, &mut VisualTestContext) {
+        let vcx = cx.add_empty_window();
+        let section = vcx.new_window_entity(|window, cx| {
+            AgentMemorySection::new(Some(PathBuf::from(ROOT)), window, cx)
+        });
+        vcx.run_until_parked();
+        (section, vcx)
+    }
+
+    #[gpui::test]
+    async fn agent_memory_section_off_makes_no_call(cx: &mut TestAppContext) {
+        init(json!({ "agent_memory": { "enabled": false } }), cx).await;
+        let calls = fake_memory(Mode::Ok, cx);
+        let (section, vcx) = open_section(cx);
+        section.update(vcx, |section, cx| section.refresh(cx));
+        vcx.run_until_parked();
+        assert!(
+            tools(&calls).is_empty(),
+            "memory off: Zeo stops calling ai-memory (R6.2)"
+        );
+    }
+
+    #[gpui::test]
+    async fn agent_memory_section_cancelled_forget_sends_nothing(cx: &mut TestAppContext) {
+        init(json!({ "agent_memory": { "enabled": true } }), cx).await;
+        let calls = fake_memory(Mode::Ok, cx);
+        let (section, vcx) = open_section(cx);
+        let before = tools(&calls).len();
+        section.update_in(vcx, |section, window, cx| {
+            section.forget("gotchas/b.md", window, cx)
+        });
+        vcx.run_until_parked();
+        let (message, detail) = vcx.pending_prompt().expect("the R6.4 confirmation");
+        assert_eq!(message, "Forget gotchas/b.md?");
+        assert_eq!(detail, "ai-memory deletes the page. This cannot be undone.");
+        vcx.simulate_prompt_answer("Cancel");
+        vcx.run_until_parked();
+        assert_eq!(tools(&calls).len(), before, "Cancel sends nothing");
+        assert!(!tools(&calls).contains(&"memory_delete_page".to_string()));
+    }
+
+    #[gpui::test]
+    async fn agent_memory_section_unreachable_is_shown_and_not_retried(cx: &mut TestAppContext) {
+        init(json!({ "agent_memory": { "enabled": true, "endpoint": "http://127.0.0.1:49374/mcp" } }), cx).await;
+        let _calls = fake_memory(Mode::Refused, cx);
+        let (section, vcx) = open_section(cx);
+        let error = section.read_with(vcx, |section, _| section.error_line());
+        assert_eq!(
+            error.as_deref(),
+            Some("ai-memory is not reachable at http://127.0.0.1:49374/mcp")
+        );
+        vcx.executor().advance_clock(Duration::from_secs(120));
+        vcx.run_until_parked();
+        assert_eq!(
+            section
+                .read_with(vcx, |section, _| section.error_line())
+                .as_deref(),
+            Some("ai-memory is not reachable at http://127.0.0.1:49374/mcp"),
+            "unchanged until Refresh"
+        );
+    }
+
+    #[gpui::test]
+    async fn agent_memory_section_unknown_project_text(cx: &mut TestAppContext) {
+        init(json!({ "agent_memory": { "enabled": true } }), cx).await;
+        let _calls = fake_memory(Mode::UnknownProject, cx);
+        let (section, vcx) = open_section(cx);
+        assert_eq!(
+            section
+                .read_with(vcx, |section, _| section.error_line())
+                .as_deref(),
+            Some("No memory recorded for default/app yet.")
+        );
+    }
+
+    #[gpui::test]
+    async fn agent_memory_section_lists_pages_and_forget_deletes_then_refreshes(
+        cx: &mut TestAppContext,
+    ) {
+        init(json!({ "agent_memory": { "enabled": true } }), cx).await;
+        let calls = fake_memory(Mode::Ok, cx);
+        let (section, vcx) = open_section(cx);
+        assert_eq!(
+            tools(&calls),
+            vec!["memory_status", "memory_recent"],
+            "once on open"
+        );
+        section.read_with(vcx, |section, _| {
+            let status = section.status_line().expect("status shown");
+            for needle in [
+                "2.6.2",
+                "http://127.0.0.1:49374/mcp",
+                "default",
+                "app",
+                "69",
+                "118",
+            ] {
+                assert!(status.contains(needle), "{needle} in {status}");
+            }
+            let pages: Vec<(&str, &str)> = section
+                .pages()
+                .iter()
+                .map(|page| (page.title.as_str(), page.path.as_str()))
+                .collect();
+            assert_eq!(
+                pages,
+                vec![("Session a", "sessions/a.md"), ("Gotcha b", "gotchas/b.md")]
+            );
+        });
+        let recent_args = calls
+            .lock()
+            .iter()
+            .find(|(tool, _)| tool == "memory_recent")
+            .map(|(_, a)| a.clone());
+        assert_eq!(recent_args.expect("recent")["limit"], json!(10));
+
+        section.update_in(vcx, |section, window, cx| {
+            section.forget("gotchas/b.md", window, cx)
+        });
+        vcx.run_until_parked();
+        vcx.simulate_prompt_answer("Forget");
+        vcx.run_until_parked();
+        let after = tools(&calls);
+        let delete_at = after
+            .iter()
+            .position(|tool| tool == "memory_delete_page")
+            .expect("delete sent after Forget");
+        assert_eq!(
+            after
+                .iter()
+                .filter(|tool| *tool == "memory_delete_page")
+                .count(),
+            1
+        );
+        assert!(
+            after[delete_at + 1..].contains(&"memory_recent".to_string()),
+            "the list refreshes after the delete: {after:?}"
+        );
+        let delete_args = calls.lock()[delete_at].1.clone();
+        assert_eq!(
+            delete_args,
+            json!({ "path": "gotchas/b.md", "workspace": "default", "project": "app" })
+        );
+    }
+
+    #[gpui::test]
+    async fn agent_memory_section_non_loopback_endpoint_note(cx: &mut TestAppContext) {
+        init(json!({ "agent_memory": { "enabled": true, "endpoint": "http://mem.example.com:8080/mcp" } }), cx).await;
+        let _calls = fake_memory(Mode::Ok, cx);
+        let (section, vcx) = open_section(cx);
+        let note = section
+            .read_with(vcx, |section, _| section.endpoint_note())
+            .expect("R7.3: the page says the host must be allowed instead");
+        assert!(note.contains("mem.example.com"), "{note}");
+    }
+}
diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml
index 9aa305a..5b4c596 100644
--- a/crates/zed/Cargo.toml
+++ b/crates/zed/Cargo.toml
@@ -70,6 +70,7 @@ action_log = { workspace = true, optional = true }
 activity_indicator.workspace = true
 agent-client-protocol.workspace = true
 agent.workspace = true
+agent_sandbox.workspace = true
 agent_servers = { workspace = true, optional = true }
 agent_settings.workspace = true
 agent_skills.workspace = true
diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs
index 4858b16..868414d 100644
--- a/crates/zed/src/main.rs
+++ b/crates/zed/src/main.rs
@@ -708,6 +708,7 @@ fn main() {
         web_search::init(cx);
         web_search_providers::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         devtools_bridge::init(app_state.clone(), cx);
+        agent_sandbox::init(cx);
         snippet_provider::init(cx);
         edit_prediction_registry::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         let prompt_builder = PromptBuilder::load(app_state.fs.clone(), stdout_is_a_pty(), cx);
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index ed8c75a..91d4972 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -6333,6 +6333,8 @@ mod tests {
                 "action",
                 "activity_indicator",
                 "agent",
+                "agent_memory",
+                "agent_sandbox",
                 "agent_tasks_panel",
                 "agents_sidebar",
                 "app_menu",
diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs
index addeb54..fee5ba1 100644
--- a/crates/zed_actions/src/lib.rs
+++ b/crates/zed_actions/src/lib.rs
@@ -174,6 +174,10 @@ pub const AGENT_SKILLS_SETTINGS_PATH: &str = "agent.skills";
 /// UI.
 pub const AGENT_SANDBOX_SETTINGS_PATH: &str = "agent.sandbox_permissions";
 
+/// `OpenSettingsAt` path of the Agent Sandbox page (ai-jail for external agents) in the settings
+/// UI.
+pub const AGENT_JAIL_SETTINGS_PATH: &str = "agent_sandbox";
+
 #[derive(PartialEq, Clone, Debug, Deserialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum OpenSettingsAtTarget {
