fix(run): ai-jail re-exec/offer, --true-yolo semantics, and relaunch after an interrupted run

ai-jail integration (`ai-memory run --yolo`):
- The re-exec built `ai-jail <flags> <exe> run …` with no `--`. ai-jail
  rejects one of its own flags after the command and `run` shares flag names
  with it, so `run claude --yolo --env GH_TOKEN=…` aborted. The invocation now
  emits `--` before the wrapped exe (forwarding a colliding flag additionally
  needs ai-jail >= 2.4.2, whose guard honors the separator; the cross-tool
  test gates on that version).
- The offer only checked for a file named ai-jail: Windows could show it, a
  host without bwrap/sandbox-exec was offered a jail that cannot start, and a
  ~/.local/bin-only install was offered and then not found by the bare
  `Command::new("ai-jail")` re-exec after the run was already cancelled.
  usable_ai_jail(os, lookup) now returns the exact binary to exec only on
  Linux/macOS with the backend present; otherwise no question is asked.

--true-yolo:
- It now implies --yolo (warning, ai-jail offer, harness dangerous mode):
  alone it used to apply Claude's bypassPermissions with no warning. It is
  interchangeable with --yolo for non-Claude harnesses, and recognized after
  native arguments (`run claude --model opus --true-yolo`), where clap leaves
  it in the native argv and it was forwarded to Claude as an unknown option.
- The claude_true_yolo config key only upgrades an explicit yolo launch, as
  its doc comment stated, instead of bypassing permissions on every run.
- Removed what never worked: three CLAUDE_CODE_DISABLE_*RM* env vars Claude
  Code does not read (absent from the 2.1.280 binary and its env reference),
  and an empty permissions.ask array that cannot clear ask rules from other
  scopes (Claude unions them). Docs now state that Claude honors explicit ask
  rules and its command-safety checks in every permission mode.

Relaunch after an interrupted run:
- A launcher killed before releasing its lease (terminal closed, ai-jail
  torn down) left the workstream held for up to 90s and the next launch failed
  after a 5s retry. An interactive launch now parses the holder and expiry from
  the 409, waits for that lease to lapse (bounded by one lease; Ctrl-C aborts),
  then proceeds. A holder that renews meanwhile is reported as live and never
  displaced; the server's busy check stays the only arbiter (security
  inventory row 13b). Non-interactive launches keep the short window.

Tests: usable_ai_jail OS/backend/Windows/exact-path, `--` placement and the
colliding-flag regression (unit + real ai-jail --dry-run), the yolo_modes
table, --true-yolo in both argv positions via real clap parses, the reduced
true-yolo argv, and HTTP-level held-lease wait / renewed-owner / Ctrl-C cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
This commit is contained in:
AkitaOnRails
2026-10-01 01:53:49 -03:00
co-authored by Claude Opus 5.5
parent 85ddc1f450
commit ae676d4918
14 changed files with 919 additions and 228 deletions
+38
View File
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- Fixed `ai-memory run --yolo`'s ai-jail re-exec aborting when the wrapped
command carried a flag that ai-jail also defines: `run claude --yolo --env
GH_TOKEN=…` failed with "flag --env after command would be passed to the
child". The invocation now separates ai-jail's sandbox flags from the
wrapped command with `--`; forwarding such a flag also needs ai-jail 2.4.2 or
later, whose guard honors the separator.
- Fixed the `--yolo` ai-jail offer appearing when accepting it could not
work. It is now shown only on Linux/macOS when both ai-jail and its sandbox
backend (`bwrap` / `sandbox-exec`) are present — never on Windows, even with a
file named `ai-jail` on `PATH` — and otherwise the run proceeds without the
question. The re-exec runs the exact binary that was found, so a
`~/.local/bin`-only ai-jail no longer fails to exec after the user accepted.
- Fixed `--true-yolo`. It now implies `--yolo` (the warning, the ai-jail
offer, and each harness's dangerous mode), so passing it alone no longer
bypassed Claude's permissions with no warning; it is interchangeable with
`--yolo` for non-Claude harnesses instead of printing "ignoring it"; and it is
recognized after native arguments (`run claude --model opus --true-yolo`)
instead of being passed to Claude as an unknown option. The `claude_true_yolo`
config key now
only upgrades an explicit `--yolo`/`--true-yolo` launch, as documented,
rather than applying `bypassPermissions` to every managed Claude run.
- Fixed relaunching right after an interrupted `ai-memory run` failing with
"workstream is already active: owned by … until …" when the previous
launcher could not release its lease (killed, terminal closed, or an
ai-jail sandbox torn down). An interactive launch now names the holder and
waits for that lease to lapse (at most one ~90-second lease; Ctrl-C aborts),
then starts by itself. A holder that renews the lease meanwhile is reported
as a launcher still running — never displaced — and non-interactive launches
keep the short retry window.
- Fixed `--true-yolo` claiming protections it never provided. It set three
`CLAUDE_CODE_DISABLE_*RM*` environment variables that Claude Code does not
read, and passed an empty `permissions.ask` array that cannot clear `ask`
rules from other settings scopes (Claude Code unions them). Both were
removed; true-yolo now forces only `bypassPermissions`, and the docs state
that Claude still honors your own `ask` rules and command-safety checks in
every mode.
### Security
- Fixed GHSA-vh98: a capture-exclusion candidate or shell argument spelled
with a leading `//` (e.g. `//repo/secret/token.txt`) self-classified as a
+7 -6
View File
@@ -288,12 +288,13 @@ pub struct RunArgs {
/// equivalent dangerous-mode option.
#[arg(long)]
pub yolo: bool,
/// Claude-only: additionally silence the residual `--dangerously-skip-permissions`
/// prompts (rm timeout/confirmation, PowerShell rm deny) and force
/// `bypassPermissions` via `--settings`. No-op for every other harness
/// (a one-line note is printed instead of being silently ignored).
/// Off by default; overrides `[claude_true_yolo]` in config.toml when
/// passed. Best paired with ai-jail — see
/// Everything `--yolo` does, plus — for Claude — forcing
/// `bypassPermissions` via `--settings` over any settings `defaultMode`.
/// Claude still honors your own explicit `ask` rules in every mode. For
/// every other harness it is interchangeable with `--yolo`; passing both
/// is redundant but fine. Off by default; `[claude_true_yolo]` in
/// config.toml applies the same Claude extra to an explicit `--yolo`
/// launch. Best paired with ai-jail — see
/// `docs/design-yolo-safety-ai-jail.md`.
#[arg(long = "true-yolo")]
pub true_yolo: bool,
+495 -46
View File
@@ -13,15 +13,14 @@ use ai_memory_core::{
};
use ai_memory_workstream::{
AmbiguousNativeSession, ExportedTranscript, FORWARDED_ENV_NAMES, LaunchMode, LaunchPlan,
LaunchRoots, ManagedHarness, NativeSessionCandidate, ai_jail_on_path,
allows_native_session_adoption, apply_claude_true_yolo, apply_yolo, build_ai_jail_invocation,
build_launch_plan, build_launch_plan_with_env, crush_global_config_path,
discover_native_session, export_transcript, has_native_session_selector, inside_ai_jail_here,
inspect_repository, kiro_explicit_session_id, kiro_harness_from_source_cursor,
kiro_selects_non_default_engine, kiro_selects_v2_engine, kiro_selects_v3_engine,
kiro_v3_resume_uses_default_store, list_native_sessions, native_session_exists,
native_session_in_checkout, omp_profile_flag, omp_profile_flag_env, store_override_vars,
wait_for_transcript_flush,
LaunchRoots, ManagedHarness, NativeSessionCandidate, allows_native_session_adoption,
apply_claude_true_yolo, apply_yolo, build_ai_jail_invocation, build_launch_plan,
build_launch_plan_with_env, crush_global_config_path, discover_native_session,
export_transcript, has_native_session_selector, inside_ai_jail_here, inspect_repository,
kiro_explicit_session_id, kiro_harness_from_source_cursor, kiro_selects_non_default_engine,
kiro_selects_v2_engine, kiro_selects_v3_engine, kiro_v3_resume_uses_default_store,
list_native_sessions, native_session_exists, native_session_in_checkout, omp_profile_flag,
omp_profile_flag_env, store_override_vars, usable_ai_jail_here, wait_for_transcript_flush,
};
use anyhow::{Context as _, Result, anyhow};
use tokio::process::Command;
@@ -38,6 +37,13 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
const HEARTBEAT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const PREPARE_BUSY_RETRY_WINDOW: Duration = Duration::from_secs(5);
const PREPARE_BUSY_RETRY_INTERVAL: Duration = Duration::from_millis(250);
/// Most an interactive launch waits for another launcher's lease to lapse: one
/// full server lease (90s) plus slack. A longer wait would mean the owner kept
/// renewing — a live launcher, which waiting cannot resolve.
const HELD_LEASE_MAX_WAIT: Duration = Duration::from_secs(100);
/// Margin past the reported expiry, so the retry lands after the server's clock
/// considers the lease lapsed.
const HELD_LEASE_EXPIRY_SLACK: Duration = Duration::from_secs(1);
const IMPORT_BATCH_EVENTS: usize = 400;
const IMPORT_BATCH_BYTES: usize = 1024 * 1024;
const ADOPTION_CANDIDATE_LIMIT: usize = 8;
@@ -115,12 +121,18 @@ pub(super) async fn run_from_with_wiring(
let automatic_harness = args.harness.is_none();
let mut native_args = args.native_args;
let trailing_yolo = remove_wrapper_yolo(&mut native_args);
let trailing_true_yolo = remove_wrapper_true_yolo(&mut native_args);
let trailing_fresh = remove_wrapper_fresh(&mut native_args);
let trailing_no_autowire = remove_wrapper_no_autowire(&mut native_args);
let yolo_requested = args.yolo || trailing_yolo;
let yolo_modes = yolo_modes(
args.yolo || trailing_yolo,
args.true_yolo || trailing_true_yolo,
config.claude_true_yolo,
);
let yolo_requested = yolo_modes.yolo;
let force_fresh = args.fresh || trailing_fresh;
let no_autowire = args.no_autowire || trailing_no_autowire;
let mut run_env = resolve_run_env(args.env_file.as_deref(), &args.env)
let run_env = resolve_run_env(args.env_file.as_deref(), &args.env)
.context("resolving --env/--env-file for the managed run")?;
if automatic_harness && !native_args.is_empty() {
return Err(anyhow!(
@@ -172,7 +184,8 @@ pub(super) async fn run_from_with_wiring(
};
let interrupted_before_spawn = CancellationToken::new();
let interrupt_task = tokio::spawn(capture_interrupts(interrupted_before_spawn.clone()));
let prepared = prepare_managed_run(&endpoint, &prepare)
let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
let prepared = prepare_managed_run(&endpoint, &prepare, interactive, &interrupted_before_spawn)
.await
.context("opening managed workstream; the agent was not started");
let prepared = match prepared {
@@ -383,18 +396,12 @@ pub(super) async fn run_from_with_wiring(
}
apply_yolo(harness, &mut plan.args);
}
// Claude-only "true yolo": opt-in, independent of `--yolo` (see
// `docs/design-yolo-safety-ai-jail.md` §4). Applied to the same
// env/args the child command is built from below.
if args.true_yolo || config.claude_true_yolo {
if harness == ManagedHarness::Claude {
apply_claude_true_yolo(harness, &mut run_env, &mut plan.args);
} else if args.true_yolo {
eprintln!(
"ai-memory: --true-yolo only affects the Claude harness; ignoring it for {}",
harness.as_str()
);
}
// Claude's extra "true yolo" bypass (`docs/design-yolo-safety-ai-jail.md`
// §4), applied to the same env/args the child command is built from below.
// Every other harness already got the plain `--yolo` mapping above, which
// is all `--true-yolo` means for them.
if yolo_modes.claude_true_yolo && harness == ManagedHarness::Claude {
apply_claude_true_yolo(harness, &mut plan.args);
}
let remove_kiro_home = if harness == ManagedHarness::KiroV3
&& let Some(native_session_id) = plan.expected_session_id.as_deref()
@@ -909,7 +916,11 @@ async fn confirm_yolo_and_maybe_reexec(
if interrupted.is_cancelled() {
return Err(anyhow!("managed run interrupted before the agent started"));
}
let ai_jail_available = ai_jail_on_path();
// Resolved once: `None` (Windows, ai-jail absent, or its sandbox backend
// absent) suppresses the offer entirely, and `Some` is the exact binary
// the re-exec runs.
let ai_jail = usable_ai_jail_here();
let ai_jail_available = ai_jail.is_some();
let confirmation = tokio::task::spawn_blocking(move || {
let stdin = io::stdin();
let mut stderr = io::stderr();
@@ -921,9 +932,9 @@ async fn confirm_yolo_and_maybe_reexec(
if !confirmation.proceed {
return Err(anyhow!("aborted: --yolo not confirmed"));
}
if !confirmation.jail {
let (true, Some(ai_jail)) = (confirmation.jail, ai_jail) else {
return Ok(());
}
};
// The re-exec replaces this process (or, off Unix, this process exits
// once the child does), so its own prepared lease must be released here
// rather than left to the 90s orphan timeout — the jailed re-run opens
@@ -941,25 +952,26 @@ async fn confirm_yolo_and_maybe_reexec(
// state survives ai-jail's ephemeral private home (ai-jail derives the
// per-harness state location from the wrapped `run <harness>` it parses).
let jail_args = build_ai_jail_invocation(&exe, &forwarded, &present, true);
reexec_under_ai_jail(&jail_args)
reexec_under_ai_jail(&ai_jail, &jail_args)
}
/// Replace this process with `ai-jail <jail_args>` on Unix (never returns on
/// success); elsewhere, spawn it, wait, and exit with its status (also never
/// returns).
/// Replace this process with `<ai_jail> <jail_args>` on Unix (never returns
/// on success); elsewhere, spawn it, wait, and exit with its status (also
/// never returns). `ai_jail` is the path [`usable_ai_jail_here`] resolved,
/// never a bare name re-resolved through `PATH`.
#[cfg(unix)]
fn reexec_under_ai_jail(jail_args: &[OsString]) -> Result<()> {
fn reexec_under_ai_jail(ai_jail: &Path, jail_args: &[OsString]) -> Result<()> {
use std::os::unix::process::CommandExt as _;
let error = std::process::Command::new("ai-jail").args(jail_args).exec();
Err(anyhow!("{error}")).context("re-executing under ai-jail")
let error = std::process::Command::new(ai_jail).args(jail_args).exec();
Err(anyhow!("{error}")).with_context(|| format!("re-executing under {}", ai_jail.display()))
}
#[cfg(not(unix))]
fn reexec_under_ai_jail(jail_args: &[OsString]) -> Result<()> {
let status = std::process::Command::new("ai-jail")
fn reexec_under_ai_jail(ai_jail: &Path, jail_args: &[OsString]) -> Result<()> {
let status = std::process::Command::new(ai_jail)
.args(jail_args)
.status()
.context("spawning ai-jail")?;
.with_context(|| format!("spawning {}", ai_jail.display()))?;
std::process::exit(status.code().unwrap_or(1));
}
@@ -1191,6 +1203,37 @@ fn remove_wrapper_yolo(args: &mut Vec<OsString>) -> bool {
args.len() != before
}
fn remove_wrapper_true_yolo(args: &mut Vec<OsString>) -> bool {
let before = args.len();
args.retain(|arg| arg != OsStr::new("--true-yolo"));
args.len() != before
}
/// The effective permission modes for a launch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct YoloModes {
/// Map the harness's dangerous-mode option, with the `--yolo` warning and
/// ai-jail offer.
yolo: bool,
/// Additionally apply Claude's `bypassPermissions` + residual-prompt
/// silencing (only acted on for the Claude harness).
claude_true_yolo: bool,
}
/// `--true-yolo` is a superset of `--yolo`: it requests yolo on every harness
/// (where, outside Claude, it is simply interchangeable with `--yolo`) plus the
/// Claude-only bypass, so passing both is redundant but harmless. The
/// `claude_true_yolo` config key only upgrades a launch that is already yolo —
/// it never turns an ordinary run into a permission-bypassing one without the
/// `--yolo` warning.
fn yolo_modes(yolo_flag: bool, true_yolo_flag: bool, config_true_yolo: bool) -> YoloModes {
let yolo = yolo_flag || true_yolo_flag;
YoloModes {
yolo,
claude_true_yolo: yolo && (true_yolo_flag || config_true_yolo),
}
}
fn remove_wrapper_fresh(args: &mut Vec<OsString>) -> bool {
let before = args.len();
args.retain(|arg| arg != OsStr::new("--fresh"));
@@ -1859,14 +1902,119 @@ async fn finish_with_retry(
async fn prepare_managed_run(
endpoint: &ServerEndpoint,
request: &PrepareManagedRunRequest,
interactive: bool,
interrupted: &CancellationToken,
) -> Result<PrepareManagedRunResponse> {
prepare_managed_run_with_retry(
let result = prepare_managed_run_with_retry(
endpoint,
request,
PREPARE_BUSY_RETRY_WINDOW,
PREPARE_BUSY_RETRY_INTERVAL,
true,
)
.await;
match result {
// Scripts, hooks, and CI keep the short window: they must never hang
// silently for up to a full lease.
Err(error) if interactive => {
wait_out_held_lease(
endpoint,
request,
error,
interrupted,
HELD_LEASE_EXPIRY_SLACK,
PREPARE_BUSY_RETRY_WINDOW,
)
.await
}
other => other,
}
}
/// After the quick retry window, an interactive launch waits out a lease left
/// behind by a launcher that could not release it (killed, terminal closed,
/// sandbox torn down) instead of failing: the 409 names the lease's expiry, so
/// wait for it to lapse and retry. A lease renewed meanwhile belongs to a
/// launcher that is still running; that is reported, never waited on or taken
/// over (the server's busy check stays the only arbiter of ownership).
async fn wait_out_held_lease(
endpoint: &ServerEndpoint,
request: &PrepareManagedRunRequest,
error: anyhow::Error,
interrupted: &CancellationToken,
slack: Duration,
retry_window: Duration,
) -> Result<PrepareManagedRunResponse> {
let Some(held) = held_lease(&error) else {
return Err(error);
};
let Some(wait) = held_lease_wait(held.expires, jiff::Timestamp::now(), slack) else {
return Err(error);
};
eprintln!(
"ai-memory: the workstream is held by {} until {} — usually a launcher that exited \
without releasing it. Waiting {}s for that lease to lapse (Ctrl-C to abort; \
`--new <name>` starts a separate workstream).",
held.owner,
held.expires,
wait.as_secs_f64().ceil()
);
tokio::select! {
biased;
() = interrupted.cancelled() => {
return Err(error.context("interrupted while waiting for the workstream lease to lapse"));
}
() = tokio::time::sleep(wait) => {}
}
match prepare_managed_run_with_retry(
endpoint,
request,
retry_window,
PREPARE_BUSY_RETRY_INTERVAL,
false,
)
.await
{
Err(retry) if held_lease(&retry).is_some() => Err(retry.context(
"the workstream is still held: its owner renewed the lease, so another launcher \
is running there; stop it, or pass `--new <name>` for a separate workstream",
)),
other => other,
}
}
/// The owner and expiry a busy `POST /workstream/runs` reports.
#[derive(Debug, Clone, PartialEq, Eq)]
struct HeldLease {
owner: String,
expires: jiff::Timestamp,
}
/// Parse the store's `workstream is already active: owned by <owner> until
/// <rfc3339>` message. `None` for any other shape (e.g. an older server).
fn parse_held_lease(message: &str) -> Option<HeldLease> {
let rest = message.strip_prefix("workstream is already active: owned by ")?;
let (owner, until) = rest.rsplit_once(" until ")?;
Some(HeldLease {
owner: owner.to_string(),
expires: until.trim().parse().ok()?,
})
}
fn held_lease(error: &anyhow::Error) -> Option<HeldLease> {
parse_held_lease(&active_workstream_conflict_message(error)?)
}
/// How long to wait for a held lease to lapse, or `None` when it expires
/// further out than [`HELD_LEASE_MAX_WAIT`] (a renewing, live owner — or a
/// badly skewed clock). An already-lapsed lease waits only the slack.
fn held_lease_wait(
expires: jiff::Timestamp,
now: jiff::Timestamp,
slack: Duration,
) -> Option<Duration> {
let remaining = Duration::try_from(expires.duration_since(now)).unwrap_or(Duration::ZERO);
(remaining <= HELD_LEASE_MAX_WAIT).then(|| remaining + slack)
}
async fn prepare_managed_run_with_retry(
@@ -1874,9 +2022,10 @@ async fn prepare_managed_run_with_retry(
request: &PrepareManagedRunRequest,
retry_window: Duration,
retry_interval: Duration,
announce_wait: bool,
) -> Result<PrepareManagedRunResponse> {
let deadline = tokio::time::Instant::now() + retry_window;
let mut reported_wait = false;
let mut reported_wait = !announce_wait;
loop {
match post_json(endpoint, "/workstream/runs", request).await {
Ok(response) => return Ok(response),
@@ -1898,16 +2047,20 @@ async fn prepare_managed_run_with_retry(
}
fn is_active_workstream_conflict(error: &anyhow::Error) -> bool {
let Some(response) = error.downcast_ref::<ServerResponseError>() else {
return false;
};
active_workstream_conflict_message(error).is_some()
}
fn active_workstream_conflict_message(error: &anyhow::Error) -> Option<String> {
let response = error.downcast_ref::<ServerResponseError>()?;
if response.status() != reqwest::StatusCode::CONFLICT {
return false;
return None;
}
serde_json::from_str::<serde_json::Value>(response.body())
.ok()
.and_then(|body| body.get("error")?.as_str().map(str::to_owned))
.is_some_and(|message| message.starts_with("workstream is already active:"))
.ok()?
.get("error")?
.as_str()
.filter(|message| message.starts_with("workstream is already active:"))
.map(str::to_owned)
}
async fn post_empty_with_retry(endpoint: &ServerEndpoint, path: &str, label: &str) -> Result<()> {
@@ -2436,6 +2589,7 @@ mod tests {
// path stays a few milliseconds.
Duration::from_secs(5),
Duration::from_millis(1),
true,
)
.await
.unwrap();
@@ -2445,6 +2599,202 @@ mod tests {
server.abort();
}
#[test]
fn held_lease_parses_the_store_busy_message() {
let held = parse_held_lease(
"workstream is already active: owned by ai-sandbox:2 until 2026-10-01T04:28:44.647329Z",
)
.expect("current store format parses");
assert_eq!(held.owner, "ai-sandbox:2");
assert_eq!(
held.expires,
"2026-10-01T04:28:44.647329Z"
.parse::<jiff::Timestamp>()
.unwrap()
);
// An older server's message carries no expiry: nothing to wait on.
assert_eq!(
parse_held_lease("workstream is already active: owned by workstation:42"),
None
);
assert_eq!(parse_held_lease("some other conflict"), None);
}
#[test]
fn held_lease_wait_is_bounded_by_one_lease() {
let now = "2026-10-01T04:00:00Z".parse::<jiff::Timestamp>().unwrap();
let at = |secs: i64| now + jiff::SignedDuration::from_secs(secs);
let slack = Duration::from_secs(1);
assert_eq!(
held_lease_wait(at(30), now, slack),
Some(Duration::from_secs(31))
);
// Already lapsed: retry right after the slack.
assert_eq!(held_lease_wait(at(-5), now, slack), Some(slack));
// Further out than one lease means a renewing (live) owner.
assert_eq!(held_lease_wait(at(600), now, slack), None);
}
fn held_lease_server(
conflicts: usize,
lease: Duration,
) -> (Router, Arc<std::sync::atomic::AtomicUsize>) {
use std::sync::atomic::{AtomicUsize, Ordering};
let attempts = Arc::new(AtomicUsize::new(0));
let handler_attempts = Arc::clone(&attempts);
let app = Router::new().route(
"/workstream/runs",
post(move || {
let attempts = Arc::clone(&handler_attempts);
async move {
if attempts.fetch_add(1, Ordering::SeqCst) < conflicts {
let until = jiff::Timestamp::now()
+ jiff::SignedDuration::try_from(lease).unwrap();
return (
StatusCode::CONFLICT,
axum::Json(serde_json::json!({
"error": format!(
"workstream is already active: owned by ai-sandbox:2 until {until}"
)
})),
)
.into_response();
}
axum::Json(PrepareManagedRunResponse {
workstream_id: WorkstreamId::new(),
workstream_name: "default".into(),
run_id: ManagedRunId::new(),
resolved_agent: Some(AgentKind::ClaudeCode),
native_session_id: None,
source_cursor: None,
sync_after: 0,
sync_through: 0,
may_adopt_existing_session: false,
})
.into_response()
}
}),
);
(app, attempts)
}
fn held_lease_request() -> PrepareManagedRunRequest {
PrepareManagedRunRequest {
workspace: "default".into(),
project: "project".into(),
cwd: "/tmp/project".into(),
repo_fingerprint: "repo".into(),
worktree_fingerprint: "worktree".into(),
agent: AgentKind::ClaudeCode,
automatic_harness: false,
available_agents: Vec::new(),
workstream: None,
new_workstream: None,
lease_owner: "workstation:43".into(),
}
}
async fn serve(app: Router) -> (ServerEndpoint, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(
ServerEndpoint::from_pair(Some(format!("http://{address}")), None),
server,
)
}
/// The Ctrl-C-then-relaunch case: a lease left behind by a launcher that
/// could not release it is waited out, then the launch proceeds by itself.
#[tokio::test]
async fn interactive_launch_waits_out_a_lapsing_lease_then_proceeds() {
let (app, attempts) = held_lease_server(1, Duration::from_millis(300));
let (endpoint, server) = serve(app).await;
let request = held_lease_request();
let first =
post_json::<_, PrepareManagedRunResponse>(&endpoint, "/workstream/runs", &request)
.await
.expect_err("the first attempt sees the held lease");
let started = std::time::Instant::now();
let prepared = wait_out_held_lease(
&endpoint,
&request,
first,
&CancellationToken::new(),
Duration::ZERO,
Duration::from_millis(50),
)
.await
.expect("proceeds once the lease lapsed");
assert_eq!(prepared.workstream_name, "default");
assert!(
started.elapsed() >= Duration::from_millis(200),
"it waited for the expiry"
);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
server.abort();
}
/// Adversarial: a live owner keeps renewing. The waiter must report it, not
/// loop forever and never take the lease.
#[tokio::test]
async fn a_renewed_lease_is_reported_as_a_live_owner_not_taken_over() {
let (app, _) = held_lease_server(usize::MAX, Duration::from_millis(150));
let (endpoint, server) = serve(app).await;
let request = held_lease_request();
let first =
post_json::<_, PrepareManagedRunResponse>(&endpoint, "/workstream/runs", &request)
.await
.expect_err("held");
let error = wait_out_held_lease(
&endpoint,
&request,
first,
&CancellationToken::new(),
Duration::ZERO,
Duration::from_millis(50),
)
.await
.expect_err("a renewing owner is never displaced");
assert!(
format!("{error:#}").contains("renewed the lease"),
"{error:#}"
);
server.abort();
}
#[tokio::test]
async fn ctrl_c_aborts_the_held_lease_wait_immediately() {
let (app, attempts) = held_lease_server(1, Duration::from_secs(60));
let (endpoint, server) = serve(app).await;
let request = held_lease_request();
let first =
post_json::<_, PrepareManagedRunResponse>(&endpoint, "/workstream/runs", &request)
.await
.expect_err("held");
let interrupted = CancellationToken::new();
interrupted.cancel();
let started = std::time::Instant::now();
let error = wait_out_held_lease(
&endpoint,
&request,
first,
&interrupted,
Duration::ZERO,
Duration::from_millis(50),
)
.await
.expect_err("interrupted");
assert!(started.elapsed() < Duration::from_secs(5));
assert!(format!("{error:#}").contains("interrupted"), "{error:#}");
assert_eq!(
attempts.load(std::sync::atomic::Ordering::SeqCst),
1,
"no retry after Ctrl-C"
);
server.abort();
}
fn auto_candidate(harness: ManagedHarness, updated: u64) -> AutoSessionCandidate {
AutoSessionCandidate {
harness,
@@ -2794,6 +3144,105 @@ mod tests {
assert_eq!(args, ["resume", "native-id"].map(OsString::from));
}
/// Right after the harness, clap parses the wrapper flags itself; once a
/// native argument starts, trailing_var_arg swallows everything after it
/// into `native_args`. `--yolo` was stripped from there, but a swallowed
/// `--true-yolo` was forwarded to Claude as an unknown option and the
/// bypass never applied. Both positions must yield the wrapper flag.
#[test]
fn true_yolo_is_a_wrapper_flag_in_either_position() {
let parse = |argv: &[&str]| {
let CliCommand::Run(args) = Cli::try_parse_from(argv).unwrap().command else {
panic!("expected run command");
};
args
};
let direct = parse(&[
"ai-memory",
"run",
"claude",
"--yolo",
"--true-yolo",
"--model",
"opus",
]);
assert!(direct.yolo && direct.true_yolo);
assert_eq!(direct.native_args, ["--model", "opus"].map(OsString::from));
let swallowed = parse(&[
"ai-memory",
"run",
"claude",
"--model",
"opus",
"--true-yolo",
]);
assert!(
!swallowed.true_yolo,
"clap leaves it in the native argv here"
);
let mut native = swallowed.native_args;
assert!(remove_wrapper_true_yolo(&mut native));
assert_eq!(native, ["--model", "opus"].map(OsString::from));
}
#[test]
fn true_yolo_flag_implies_yolo_on_every_harness() {
assert_eq!(
yolo_modes(false, true, false),
YoloModes {
yolo: true,
claude_true_yolo: true
},
"--true-yolo alone must still warn/offer ai-jail and map the harness's yolo"
);
// Both together are redundant, never an error or a different result.
assert_eq!(
yolo_modes(true, true, false),
yolo_modes(false, true, false)
);
}
#[test]
fn plain_yolo_does_not_bypass_claude_permissions_by_itself() {
assert_eq!(
yolo_modes(true, false, false),
YoloModes {
yolo: true,
claude_true_yolo: false
}
);
assert_eq!(
yolo_modes(false, false, false),
YoloModes {
yolo: false,
claude_true_yolo: false
}
);
}
/// The config key upgrades a yolo launch, but alone must never turn an
/// ordinary managed run into a `bypassPermissions` one with no `--yolo`
/// warning (it previously did).
#[test]
fn claude_true_yolo_config_only_upgrades_a_yolo_launch() {
assert_eq!(
yolo_modes(false, false, true),
YoloModes {
yolo: false,
claude_true_yolo: false
}
);
assert_eq!(
yolo_modes(true, false, true),
YoloModes {
yolo: true,
claude_true_yolo: true
}
);
}
#[test]
fn wrapper_fresh_parses_before_or_after_the_harness() {
let cli = Cli::try_parse_from(["ai-memory", "run", "--fresh", "codex"]).unwrap();
+1 -1
View File
@@ -127,7 +127,7 @@ struct JsonOutput {
/// Pick a local checkout and harness, then delegate to managed `run`.
pub async fn run(config: &Config, args: ShowArgs) -> Result<i32> {
if args.json && (args.yolo || args.fresh || !args.native_args.is_empty()) {
if args.json && (args.yolo || args.true_yolo || args.fresh || !args.native_args.is_empty()) {
bail!("--json only lists launch options; do not combine it with launch arguments");
}
let root = std::env::current_dir()
+8 -9
View File
@@ -407,15 +407,14 @@ pub struct Config {
/// `run_autowire = false`, or per launch with `ai-memory run --no-autowire`.
pub run_autowire: bool,
/// Off by default. When true, a Claude `ai-memory run --yolo` additionally
/// applies [`apply_claude_true_yolo`](ai_memory_workstream::apply_claude_true_yolo):
/// it disables the residual `rm`-prompt env vars and injects
/// `--settings` forcing `bypassPermissions`, so Claude Code stops
/// pausing even under `--dangerously-skip-permissions`. No-op for every
/// other harness. Best paired with ai-jail (see
/// `docs/design-yolo-safety-ai-jail.md`), since it does not widen a
/// user's own `deny`/`ask` rules. Set with `AI_MEMORY_CLAUDE_TRUE_YOLO=true`
/// or `claude_true_yolo = true` in config.toml; overridden per launch by
/// `ai-memory run --true-yolo`.
/// applies [`apply_claude_true_yolo`](ai_memory_workstream::apply_claude_true_yolo),
/// injecting `--settings` that forces `bypassPermissions` over any
/// settings `defaultMode`. It never turns a launch without `--yolo` into a
/// bypassing one, and it cannot silence the user's own `ask` rules, which
/// Claude Code enforces in every mode. No-op for every other harness. Best
/// paired with ai-jail (see `docs/design-yolo-safety-ai-jail.md`). Set with
/// `AI_MEMORY_CLAUDE_TRUE_YOLO=true` or `claude_true_yolo = true` in
/// config.toml; `ai-memory run --true-yolo` requests it per launch.
pub claude_true_yolo: bool,
/// Strip root-level `anyOf`/`oneOf`/`allOf` from MCP tool input
/// schemas (e.g. `memory_read_page`'s "exactly one of path/query"
@@ -3,15 +3,16 @@
//! Unit tests in `ai-memory-workstream::jail` prove the pure argv assembly and
//! per-OS detection. These tests assert the *cross-tool* contract: the argv
//! `build_ai_jail_invocation` produces is one the real `ai-jail` binary accepts
//! and forwards unchanged. The real-`ai-jail` test skips cleanly when ai-jail
//! (or, on Linux, `bwrap`) is not installed, mirroring the opt-in discipline of
//! and forwards unchanged. The real-`ai-jail` tests skip cleanly when the
//! feature itself would not offer ai-jail on this host (not installed, no
//! sandbox backend, or Windows), mirroring the opt-in discipline of
//! `tests/e2e/handoff_smoke.sh`, so CI without a sandbox stays green.
use std::ffi::OsString;
use std::path::Path;
use std::process::Command;
use ai_memory_workstream::build_ai_jail_invocation;
use ai_memory_workstream::{build_ai_jail_invocation, usable_ai_jail_here};
fn forwarded() -> Vec<OsString> {
["run", "claude", "--yolo"]
@@ -52,6 +53,9 @@ fn invocation_puts_all_sandbox_flags_before_the_wrapped_exe() {
&["/usr/local/bin/ai-memory", "run", "claude", "--yolo"],
"the wrapped command must be forwarded verbatim, right after the exe"
);
// The `--` separator sits immediately before the exe, so no forwarded
// flag can ever be parsed as one of ai-jail's own.
assert_eq!(strs[exe_pos - 1], "--", "`--` must precede the wrapped exe");
// `--agent-state` is a bare toggle: it must be followed by another flag or
// the exe, never by a value ai-jail would misread as the command.
@@ -61,7 +65,7 @@ fn invocation_puts_all_sandbox_flags_before_the_wrapped_exe() {
.expect("agent-state flag");
let after = &strs[agent_state + 1];
assert!(
after.starts_with("--") || after == "/usr/local/bin/ai-memory",
after.starts_with("--"),
"--agent-state must be a bare toggle, but is followed by {after:?}"
);
}
@@ -89,63 +93,43 @@ fn invocation_emits_one_bare_env_flag_per_present_name() {
assert!(!strs.iter().any(|s| s == "--agent-state"));
}
/// Locate `ai-jail` the same way the feature does; `None` ⇒ skip.
fn ai_jail_path() -> Option<std::path::PathBuf> {
if let Some(path) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path) {
let candidate = dir.join("ai-jail");
if candidate.is_file() {
return Some(candidate);
}
}
}
let home = std::env::var_os("HOME")?;
let candidate = Path::new(&home).join(".local/bin/ai-jail");
candidate.is_file().then_some(candidate)
}
fn have_bwrap() -> bool {
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|d| d.join("bwrap").is_file()))
.unwrap_or(false)
}
/// The real integration check: the argv we build is accepted by the installed
/// `ai-jail` under `--dry-run` (which prints the sandbox command without
/// executing), and the wrapped `ai-memory run claude --yolo` survives verbatim.
/// A malformed invocation — e.g. a value-taking `--agent-state` swallowing the
/// exe — fails here. Skips when ai-jail (or Linux `bwrap`) is absent.
#[test]
fn real_ai_jail_dry_run_accepts_and_forwards_the_invocation() {
let Some(ai_jail) = ai_jail_path() else {
eprintln!("skipping: ai-jail not installed");
return;
/// Run the real `ai-jail --dry-run` (prints the sandbox command without
/// executing it) over the argv built for `forwarded`, wrapping this test binary
/// so any failure is about the argv shape rather than an unresolvable command.
/// `None` ⇒ the feature would not offer ai-jail on this host, so skip.
fn real_dry_run(forwarded: &[OsString]) -> Option<(std::process::Output, String, String)> {
let Some(ai_jail) = usable_ai_jail_here() else {
eprintln!("skipping: ai-jail is not usable here (absent, no sandbox backend, or Windows)");
return None;
};
if cfg!(target_os = "linux") && !have_bwrap() {
eprintln!("skipping: bwrap not installed (Linux ai-jail backend)");
return;
}
// Wrap a program that certainly exists, so any failure is about our argv
// shape, not an unresolvable command.
let exe = std::env::current_exe().expect("test binary path");
let argv = build_ai_jail_invocation(&exe, &forwarded(), &["AI_MEMORY_SERVER_URL"], true);
let argv = build_ai_jail_invocation(&exe, forwarded, &["AI_MEMORY_SERVER_URL"], true);
let output = Command::new(&ai_jail)
.arg("--dry-run")
.args(&argv)
.output()
.expect("run ai-jail --dry-run");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Some((output, combined, exe.to_string_lossy().into_owned()))
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
/// The real integration check: the argv we build is accepted by the installed
/// `ai-jail`, and the wrapped `ai-memory run claude --yolo` survives verbatim.
/// A malformed invocation — e.g. a value-taking `--agent-state` swallowing the
/// exe — fails here.
#[test]
fn real_ai_jail_dry_run_accepts_and_forwards_the_invocation() {
let Some((output, combined, exe)) = real_dry_run(&forwarded()) else {
return;
};
assert!(
output.status.success(),
"ai-jail --dry-run rejected the invocation:\nargv={argv:?}\nstdout={stdout}\nstderr={stderr}"
"ai-jail --dry-run rejected the invocation:\n{combined}"
);
// The wrapped command must appear intact in the printed plan.
for token in ["run", "claude", "--yolo"] {
assert!(
combined.contains(token),
@@ -153,7 +137,59 @@ fn real_ai_jail_dry_run_accepts_and_forwards_the_invocation() {
);
}
assert!(
combined.contains(&exe.to_string_lossy().into_owned()),
combined.contains(&exe),
"dry-run plan should name the wrapped ai-memory exe; plan was:\n{combined}"
);
}
/// First ai-jail release whose post-command flag guard honors `--`. Older ones
/// reject a child `--env` even after the separator (their error text still
/// says "use --"), so the cross-tool regression below can only bite from here.
const AI_JAIL_HONORS_SEPARATOR: (u32, u32, u32) = (2, 4, 2);
fn ai_jail_version(ai_jail: &Path) -> Option<(u32, u32, u32)> {
let output = Command::new(ai_jail).arg("--version").output().ok()?;
let text = String::from_utf8_lossy(&output.stdout);
let mut parts = text.split_whitespace().nth(1)?.split('.');
let mut next = || parts.next()?.parse::<u32>().ok();
Some((next()?, next()?, next()?))
}
/// Regression for the rejected `ai-memory run claude --yolo --env GH_TOKEN=…`:
/// ai-jail refuses one of its own flags after the command unless a `--`
/// separates them, so a forwarded `--env`/`--network` must reach the child
/// intact instead of aborting the launch. The `--` placement itself is pinned
/// unconditionally by the `ai-memory-workstream` unit tests.
#[test]
fn real_ai_jail_dry_run_forwards_child_flags_that_collide_with_its_own() {
if let Some(ai_jail) = usable_ai_jail_here()
&& ai_jail_version(&ai_jail).is_none_or(|version| version < AI_JAIL_HONORS_SEPARATOR)
{
eprintln!(
"skipping: this ai-jail predates {AI_JAIL_HONORS_SEPARATOR:?} and ignores `--` in its post-command flag guard"
);
return;
}
let forwarded: Vec<OsString> = [
"run",
"claude",
"--yolo",
"--env",
"GH_TOKEN=placeholder",
"--network",
]
.into_iter()
.map(OsString::from)
.collect();
let Some((output, combined, _)) = real_dry_run(&forwarded) else {
return;
};
assert!(
output.status.success(),
"ai-jail rejected a forwarded child flag that shares its name:\n{combined}"
);
assert!(
combined.contains("GH_TOKEN=placeholder"),
"the child's --env value must be forwarded verbatim; plan was:\n{combined}"
);
}
+17 -46
View File
@@ -469,33 +469,22 @@ pub fn apply_yolo(harness: ManagedHarness, args: &mut Vec<OsString>) {
}
/// Opt-in Claude-only "true yolo": on top of [`apply_yolo`]'s
/// `--dangerously-skip-permissions`, silence the residual prompts Claude Code
/// still shows (a 2-minute `rm` timeout/confirmation, and the PowerShell `rm`
/// deny) and force `bypassPermissions` on the argv so CLI-flag precedence
/// beats a user's own settings.json `defaultMode`. Does not widen a user's
/// own `deny`/`ask` rules (those union across levels) — see
/// `--dangerously-skip-permissions`, force `bypassPermissions` through
/// `--settings`, whose CLI-flag precedence beats a `defaultMode` in the user's
/// or project's settings.json. Claude Code still enforces explicit `ask` and
/// `deny` rules and its own command-safety checks in every permission mode
/// (documented: "Actions no mode auto-approves"), and `--settings` permission
/// arrays union with the other scopes rather than replacing them — so this
/// cannot silence an `ask` rule; the user removes those. See
/// `docs/design-yolo-safety-ai-jail.md` §4. A no-op for every harness other
/// than [`ManagedHarness::Claude`]; callers print their own one-line note
/// when that happens (documented, not silently ignored).
pub fn apply_claude_true_yolo(
harness: ManagedHarness,
env: &mut Vec<(String, String)>,
args: &mut Vec<OsString>,
) {
/// than [`ManagedHarness::Claude`].
pub fn apply_claude_true_yolo(harness: ManagedHarness, args: &mut Vec<OsString>) {
if harness != ManagedHarness::Claude {
return;
}
let mut set = |name: &str, value: &str| {
env.retain(|(key, _)| key != name);
env.push((name.to_string(), value.to_string()));
};
set("CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT", "1");
set("CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT", "1");
// A no-op off Windows; harmless to set everywhere.
set("CLAUDE_CODE_DISABLE_POWERSHELL_CMD_RM_DENY", "1");
args.push(OsString::from("--settings"));
args.push(OsString::from(
r#"{"permissions":{"defaultMode":"bypassPermissions","ask":[]}}"#,
r#"{"permissions":{"defaultMode":"bypassPermissions"}}"#,
));
}
@@ -1573,45 +1562,27 @@ mod tests {
}
#[test]
fn apply_claude_true_yolo_sets_env_and_settings_for_claude() {
let mut env = vec![("EXISTING".to_string(), "kept".to_string())];
fn apply_claude_true_yolo_forces_bypass_permissions_for_claude() {
let mut args = vec![OsString::from("--model"), OsString::from("opus")];
apply_claude_true_yolo(ManagedHarness::Claude, &mut env, &mut args);
assert_eq!(
env,
vec![
("EXISTING".to_string(), "kept".to_string()),
(
"CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT".to_string(),
"1".to_string()
),
(
"CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT".to_string(),
"1".to_string()
),
(
"CLAUDE_CODE_DISABLE_POWERSHELL_CMD_RM_DENY".to_string(),
"1".to_string()
),
]
);
apply_claude_true_yolo(ManagedHarness::Claude, &mut args);
assert_eq!(
strings(&args),
[
"--model",
"opus",
"--settings",
r#"{"permissions":{"defaultMode":"bypassPermissions","ask":[]}}"#,
r#"{"permissions":{"defaultMode":"bypassPermissions"}}"#,
]
);
// `--settings` permission arrays union with the other scopes, so an
// empty `ask` would only suggest a protection it cannot provide.
assert!(!strings(&args).iter().any(|arg| arg.contains("\"ask\"")));
}
#[test]
fn apply_claude_true_yolo_is_noop_for_other_harnesses() {
let mut env = Vec::new();
let mut args = vec![OsString::from("--yolo")];
apply_claude_true_yolo(ManagedHarness::Codex, &mut env, &mut args);
assert!(env.is_empty());
apply_claude_true_yolo(ManagedHarness::Codex, &mut args);
assert_eq!(strings(&args), ["--yolo"]);
}
+163 -30
View File
@@ -28,32 +28,52 @@ pub const FORWARDED_ENV_NAMES: &[&str] = &[
"OPENROUTER_API_KEY",
];
/// Whether `ai-jail` resolves through a `which`-style lookup. `lookup` is
/// injected so the resolution logic (PATH, `~/.local/bin`) is exercised by
/// [`ai_jail_on_path`] while this stays a pure predicate for tests.
/// The ai-jail binary the `--yolo` offer may re-exec under, or `None` when
/// the offer must not be shown at all (docs/design-yolo-safety-ai-jail.md §2).
///
/// The offer is only made when accepting it can succeed: ai-jail does not
/// support Windows, and on Linux/macOS it cannot start without its sandbox
/// backend (`bwrap` / `sandbox-exec`). Accepting an offer that then fails
/// would cancel the already-prepared managed run for nothing. The returned
/// path is the one to exec, so the re-exec can never resolve a different —
/// or missing — binary than the one this check found. `lookup` is injected
/// so every OS branch is unit-tested without a real `PATH`.
#[must_use]
pub fn ai_jail_installed(lookup: impl Fn(&str) -> Option<PathBuf>) -> bool {
lookup("ai-jail").is_some()
pub fn usable_ai_jail(os: JailOs, lookup: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
let backend = match os {
JailOs::Linux => "bwrap",
JailOs::MacOs => "sandbox-exec",
JailOs::Windows => return None,
};
lookup(backend)?;
lookup("ai-jail")
}
/// Real `ai-jail` lookup: `PATH`, falling back to `~/.local/bin/ai-jail`
/// (ai-jail's own documented install location when it is not on `PATH`).
/// [`usable_ai_jail`] for this host: the backend on `PATH`, and ai-jail on
/// `PATH` falling back to `~/.local/bin/ai-jail` (ai-jail's own documented
/// install location when that directory is not on `PATH`).
#[must_use]
pub fn ai_jail_on_path() -> bool {
ai_jail_installed(resolve_ai_jail)
pub fn usable_ai_jail_here() -> Option<PathBuf> {
usable_ai_jail(current_jail_os(), |name| match find_on_path(name) {
Some(path) => Some(path),
None if name == "ai-jail" => home_local_bin_ai_jail(),
None => None,
})
}
fn resolve_ai_jail(name: &str) -> Option<PathBuf> {
if let Some(path) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| is_executable_file(candidate))
}
fn home_local_bin_ai_jail() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
let candidate = PathBuf::from(home).join(".local").join("bin").join(name);
let candidate = PathBuf::from(home)
.join(".local")
.join("bin")
.join("ai-jail");
is_executable_file(&candidate).then_some(candidate)
}
@@ -156,8 +176,15 @@ fn read_linux_hostname() -> Option<String> {
/// Build the argument vector for `ai-jail` (excluding the `ai-jail` program
/// name itself): `--network`, an optional bare `--agent-state` toggle, one
/// `--env NAME` per already-filtered present name, then the wrapped
/// executable and its forwarded arguments in order.
/// `--env NAME` per already-filtered present name, a `--` separator, then the
/// wrapped executable and its forwarded arguments in order.
///
/// The `--` is required, not cosmetic. ai-jail refuses one of its own flags
/// appearing after the command (it cannot tell whether
/// `ai-jail cmd --network` means the sandbox or the child), and `ai-memory
/// run` shares flag names with ai-jail — a forwarded `run claude --env
/// GH_TOKEN=…` was rejected outright. After `--`, ai-jail passes everything
/// to the wrapped command verbatim.
///
/// `--agent-state` is a boolean toggle in ai-jail (`--agent-state` /
/// `--no-agent-state`), not a valued flag — it persists the harness's own
@@ -181,6 +208,7 @@ pub fn build_ai_jail_invocation(
argv.push(OsString::from("--env"));
argv.push(OsString::from(*name));
}
argv.push(OsString::from("--"));
argv.push(exe.as_os_str().to_os_string());
argv.extend(forwarded_args.iter().cloned());
argv
@@ -197,16 +225,75 @@ mod tests {
}
#[test]
fn ai_jail_installed_true_when_lookup_resolves() {
assert!(ai_jail_installed(|name| {
assert_eq!(name, "ai-jail");
Some(PathBuf::from("/usr/bin/ai-jail"))
}));
fn usable_ai_jail_returns_the_resolved_binary_with_its_backend() {
let found = |names: &'static [&'static str]| {
move |name: &str| {
names
.contains(&name)
.then(|| PathBuf::from(format!("/opt/bin/{name}")))
}
};
assert_eq!(
usable_ai_jail(JailOs::Linux, found(&["ai-jail", "bwrap"])),
Some(PathBuf::from("/opt/bin/ai-jail"))
);
assert_eq!(
usable_ai_jail(JailOs::MacOs, found(&["ai-jail", "sandbox-exec"])),
Some(PathBuf::from("/opt/bin/ai-jail"))
);
}
#[test]
fn ai_jail_installed_false_when_lookup_misses() {
assert!(!ai_jail_installed(|_| None));
fn usable_ai_jail_is_none_when_ai_jail_is_missing() {
assert_eq!(
usable_ai_jail(JailOs::Linux, |name| {
(name == "bwrap").then(|| PathBuf::from("/usr/bin/bwrap"))
}),
None
);
}
/// ai-jail present but its sandbox backend absent: accepting the offer
/// would cancel the prepared run and then fail, so it is not offered.
#[test]
fn usable_ai_jail_is_none_without_the_os_sandbox_backend() {
let only_ai_jail =
|name: &str| (name == "ai-jail").then(|| PathBuf::from("/usr/bin/ai-jail"));
assert_eq!(usable_ai_jail(JailOs::Linux, only_ai_jail), None);
assert_eq!(usable_ai_jail(JailOs::MacOs, only_ai_jail), None);
// The other OS's backend does not count.
let linux_backend_on_macos = |name: &str| {
matches!(name, "ai-jail" | "bwrap").then(|| PathBuf::from(format!("/usr/bin/{name}")))
};
assert_eq!(usable_ai_jail(JailOs::MacOs, linux_backend_on_macos), None);
}
/// ai-jail is unsupported on Windows: even a file named `ai-jail` on PATH
/// (a Git-Bash or WSL shim) must not produce the offer, and the lookup is
/// never consulted.
#[test]
fn usable_ai_jail_is_never_offered_on_windows() {
assert_eq!(
usable_ai_jail(JailOs::Windows, |name| {
panic!("Windows must not look up {name}")
}),
None
);
}
/// The returned path is the exec target, so a binary found only through
/// the `~/.local/bin` fallback is exec'd from there rather than re-resolved
/// through `PATH` (where it would not be found).
#[test]
fn usable_ai_jail_returns_the_exact_lookup_path_to_exec() {
let fallback = PathBuf::from("/home/dev/.local/bin/ai-jail");
let expected = fallback.clone();
let found = usable_ai_jail(JailOs::Linux, move |name| match name {
"bwrap" => Some(PathBuf::from("/usr/bin/bwrap")),
"ai-jail" => Some(fallback.clone()),
_ => None,
});
assert_eq!(found, Some(expected));
}
#[test]
@@ -273,6 +360,7 @@ mod tests {
"AI_MEMORY_SERVER_URL",
"--env",
"ANTHROPIC_API_KEY",
"--",
"/usr/local/bin/ai-memory",
"run",
"claude",
@@ -285,7 +373,10 @@ mod tests {
fn build_ai_jail_invocation_omits_agent_state_when_none() {
let exe = Path::new("/usr/local/bin/ai-memory");
let argv = build_ai_jail_invocation(exe, &[], &[], false);
assert_eq!(strings(&argv), ["--network", "/usr/local/bin/ai-memory"]);
assert_eq!(
strings(&argv),
["--network", "--", "/usr/local/bin/ai-memory"]
);
}
#[test]
@@ -294,7 +385,49 @@ mod tests {
let argv = build_ai_jail_invocation(exe, &[], &["CLAUDE_CONFIG_DIR"], false);
assert_eq!(
strings(&argv),
["--network", "--env", "CLAUDE_CONFIG_DIR", "/bin/ai-memory"]
[
"--network",
"--env",
"CLAUDE_CONFIG_DIR",
"--",
"/bin/ai-memory"
]
);
}
/// Regression: forwarded `run` flags that share a name with ai-jail's own
/// (`--env`, `--network`) must land after the `--` separator, where ai-jail
/// passes them to the wrapped command instead of rejecting them.
#[test]
fn build_ai_jail_invocation_places_colliding_child_flags_after_separator() {
let exe = Path::new("/bin/ai-memory");
let forwarded = [
"run",
"claude",
"--yolo",
"--env",
"GH_TOKEN=placeholder",
"--network",
]
.map(OsString::from);
let argv = strings(&build_ai_jail_invocation(exe, &forwarded, &[], true));
let separator = argv
.iter()
.position(|arg| arg == "--")
.expect("separator present");
assert_eq!(argv[separator + 1], "/bin/ai-memory");
assert_eq!(
&argv[separator + 2..],
[
"run",
"claude",
"--yolo",
"--env",
"GH_TOKEN=placeholder",
"--network"
]
);
// Before the separator, only ai-memory's own sandbox flags appear.
assert_eq!(&argv[..separator], ["--network", "--agent-state"]);
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ pub use harness::{
store_override_vars,
};
pub use jail::{
FORWARDED_ENV_NAMES, JailEnv, JailOs, ai_jail_installed, ai_jail_on_path,
build_ai_jail_invocation, current_jail_os, inside_ai_jail, inside_ai_jail_here,
FORWARDED_ENV_NAMES, JailEnv, JailOs, build_ai_jail_invocation, current_jail_os,
inside_ai_jail, inside_ai_jail_here, usable_ai_jail, usable_ai_jail_here,
};
pub use repository::{RepositoryIdentity, inspect_repository};
pub use transcript::{
+24 -10
View File
@@ -168,10 +168,13 @@ ai-memory run --yolo claude
`Enter`/`y`/`yes` proceeds (the default); `n`/`no` aborts before anything
launches.
- **The ai-jail offer.** If [ai-jail](https://github.com/akitaonrails/ai-jail)
is on `PATH` (or `~/.local/bin/ai-jail`) and you are not already inside it,
a second question offers to re-run the session inside it. Accepting
re-execs the original command under `ai-jail --network --agent-state
--env <NAME>...`, forwarding only the credential/config
is usable — on Linux/macOS, installed on `PATH` (or `~/.local/bin/ai-jail`),
with its sandbox backend present (`bwrap` on Linux, `sandbox-exec` on
macOS) — and you are not already inside it, a second question offers to
re-run the session inside it. When it is not usable (or on Windows) there is
no second question; the run just proceeds. Accepting re-execs the original
command under `ai-jail --network --agent-state --env <NAME>... --`,
forwarding only the credential/config
environment variables that are already set (server/hook URL,
`CLAUDE_CONFIG_DIR`, provider API keys, etc.) — `--network` keeps the
loopback ai-memory server reachable while still sandboxing the filesystem.
@@ -181,12 +184,23 @@ ai-memory run --yolo claude
Detection is Linux (`ai-sandbox` hostname) / macOS (`PS1` starting with
`(jail) `); it fails open (shows the warning) when undetectable, never
open to skipping it silently.
- **Claude "true yolo".** `--dangerously-skip-permissions` alone still pauses
Claude Code on `permissions.ask`/`deny` rules and on a 2-minute `rm`
confirmation. Opt in with `--true-yolo` (or `claude_true_yolo = true` in
`config.toml` / `AI_MEMORY_CLAUDE_TRUE_YOLO=true`) to also silence those —
Claude-only, off by default, and best paired with ai-jail since it does not
widen your own `deny`/`ask` rules.
- **Claude "true yolo".** `--true-yolo` includes everything `--yolo` does
(`ai-memory run claude --true-yolo` is enough; adding `--yolo` too is
harmless) and, for Claude, also forces `bypassPermissions` over any
`defaultMode` in your settings. For other harnesses it is the same as
`--yolo`. `claude_true_yolo = true` in `config.toml` /
`AI_MEMORY_CLAUDE_TRUE_YOLO=true` applies the Claude extra to every `--yolo`
launch, never to a run without it.
**It cannot remove your own `ask` rules**: Claude Code honors explicit
`permissions.ask` rules (and its built-in command-safety checks) in every
mode, so a rule like `Bash(docker run *)` in `~/.claude/settings.json` still
pauses the run. For a pause-free sandbox, drop those `ask` entries — `deny`
rules block without pausing, so they can stay. Best paired with ai-jail.
- **Passing extra env, e.g. a GitHub token.** `ai-memory run claude --yolo
--env GH_TOKEN="$(gh auth token)"` forwards it into the jailed agent (needs
ai-jail 2.4.2 or later when you accept the jail offer). This
hands a sandboxed agent your token, so only do it for work you'd trust it
with; it is deliberately never forwarded automatically.
See [`design-yolo-safety-ai-jail.md`](design-yolo-safety-ai-jail.md) for the
full contract.
+62 -25
View File
@@ -3,9 +3,8 @@
Status: accepted (release/2.5). Tracks the 2.5 feature that makes
`ai-memory run … --yolo` warn before it disarms an agent's safety prompts,
offers to run the session inside [ai-jail](https://github.com/akitaonrails/ai-jail)
when it is installed, and adds an opt-in "true yolo" for Claude Code that
silences the residual permission pauses `--dangerously-skip-permissions`
leaves behind.
when it is usable, and adds an opt-in "true yolo" that implies `--yolo` and,
for Claude Code, also forces `bypassPermissions` over a settings `defaultMode`.
## Motivation
@@ -19,8 +18,11 @@ every tool call with no confirmation. Three gaps:
agent, but nothing connects the two — the user must remember to type
`ai-jail ai-memory run …` themselves.
3. **Claude still pauses.** Even with `--dangerously-skip-permissions`, Claude
Code still prompts on `permissions.ask`/`deny` rules and on critical-path
`rm` (a 2-minute timeout prompt), so an "unattended" yolo run stalls.
Code still prompts on explicit `permissions.ask` rules and on its own
command-safety checks (e.g. "Contains brace with quote character (expansion
obfuscation)"), so an "unattended" yolo run can stall. Anthropic documents
these under "Actions no mode auto-approves": no permission mode — including
`bypassPermissions` — skips them.
## Non-goals
@@ -53,8 +55,17 @@ scripts, hooks, and CI keep working unchanged.
### 2. ai-jail detect + offer (Linux/macOS)
If `ai-jail` is on `PATH` (`command -v ai-jail`, fallback `~/.local/bin/ai-jail`)
and we are not already jailed, the prompt gains a second question:
The offer appears only when accepting it can actually work
(`usable_ai_jail`): on Linux or macOS, with the ai-jail binary on `PATH`
(fallback `~/.local/bin/ai-jail`) **and** its sandbox backend on `PATH`
(`bwrap` on Linux, `sandbox-exec` on macOS). It never appears on Windows, where
ai-jail is unsupported, even if a file named `ai-jail` happens to be on `PATH`.
When ai-jail is not usable there is no question at all — the run proceeds
directly after the §1 warning. The re-exec runs the exact path this check
resolved, never a bare `ai-jail` re-looked-up through `PATH` (which missed a
`~/.local/bin`-only install after the user had already accepted).
When usable and we are not already jailed, the prompt gains a second question:
```
ai-jail is installed. Re-run this session inside it? [Y/n]
@@ -68,9 +79,19 @@ ai-jail --network --agent-state <state> \
--env AI_MEMORY_SERVER_URL --env AI_MEMORY_HOOK_URL \
--env ANTHROPIC_API_KEY --env CLAUDE_CODE_OAUTH_TOKEN \
--env CLAUDE_CONFIG_DIR --env … \
<current_exe> run <harness> … --yolo
-- <current_exe> run <harness> … --yolo
```
- **`--` before the wrapped command.** ai-jail refuses one of its own flags
appearing after the command, because it cannot tell whether
`ai-jail cmd --network` means the sandbox or the child. `ai-memory run`
shares flag names with ai-jail (`--env`, …), so without the separator a
forwarded `run claude --env GH_TOKEN=…` aborted the launch. After `--`
ai-jail passes everything to the wrapped command verbatim — from ai-jail
2.4.2; earlier releases' guard ignores `--` (despite its error text
suggesting it), so a colliding forwarded flag still fails there. ai-memory
emits the separator regardless, as the documented contract.
- **Re-exec**, not a nested spawn: `std::env::current_exe()` + the original
`args_os()`. ai-jail forwards the wrapped argv verbatim and already parses
`ai-memory run <harness>` (it keeps both the `ai-memory` binary and the
@@ -120,24 +141,40 @@ skipped and the run proceeds directly — a user who typed
### 4. Claude "true yolo" (opt-in, all OSes; recommended only under ai-jail)
`--dangerously-skip-permissions` alone still pauses. Opt-in
`[run] claude_true_yolo` (config) / `--true-yolo` (flag) additionally, **for
the Claude harness only**:
Opt-in `[run] claude_true_yolo` (config) / `--true-yolo` (flag) additionally,
**for the Claude harness only**, injects
`--settings '{"permissions":{"defaultMode":"bypassPermissions"}}'` on the
Claude argv. CLI-flag precedence sits above user and project settings, so a
`defaultMode` there (e.g. `auto` or `acceptEdits`) cannot narrow the run.
- Sets `CLAUDE_CODE_DISABLE_DANGEROUS_RM_TIMEOUT=1`,
`CLAUDE_CODE_DISABLE_SUBSTITUTION_RM_PROMPT=1`, and
`CLAUDE_CODE_DISABLE_POWERSHELL_CMD_RM_DENY=1` in the child env (the last is a
no-op off Windows; harmless to set everywhere). These remove the residual
`rm` prompts.
- Injects `--settings '{"permissions":{"defaultMode":"bypassPermissions","ask":[]}}'`
on the Claude argv (CLI-flag precedence sits above user settings). This does
not remove a user's own `deny`/`ask` rules (those union across levels), so
true-yolo is documented as "best paired with a clean sandbox," i.e. ai-jail.
What it deliberately does **not** claim to do, verified against Claude Code
2.1.280 and its documentation:
Off by default. When enabled without ai-jail (and interactive), the warning
text says so. Only applies to `ManagedHarness::Claude`; a no-op for other
harnesses (documented, not silently ignored — a one-line note if `--true-yolo`
is passed with a non-Claude harness).
- It cannot silence an explicit `ask` rule. Claude Code enforces `ask` and
`deny` rules in every permission mode, and `--settings` permission arrays
*union* with the user/project/local scopes instead of replacing them, so an
empty `ask` array there is a no-op (earlier releases injected one). To run
without those pauses, remove the `ask` rules from your own settings; `deny`
rules never pause — they block — so keeping them costs no interruptions.
- It cannot skip Claude Code's built-in command-safety checks.
- Earlier releases also set three `CLAUDE_CODE_DISABLE_*RM*` environment
variables. Claude Code reads none of them (they are absent from its binary
and its env-var reference), so they were removed rather than left implying a
protection that never existed.
True-yolo is documented as "best paired with a clean sandbox," i.e. ai-jail.
Off by default. **`--true-yolo` is a superset of `--yolo`**: it implies
`--yolo` (the §1 warning, the §2 offer, and each harness's dangerous-mode
mapping) and adds the Claude extras above, so passing both is redundant but
harmless. For every non-Claude harness it is simply interchangeable with
`--yolo`. Like `--yolo`, it is recognized anywhere after `run` — including
after native arguments (`run claude --model opus --true-yolo`), where clap
leaves it in the native argv — and never forwarded to the harness as an
unknown flag.
The `claude_true_yolo` config key only upgrades a launch that is already
`--yolo`/`--true-yolo`; on its own it never turns an ordinary run into a
permission-bypassing one without the warning.
## OS support matrix
@@ -152,7 +189,7 @@ is passed with a non-Claude harness).
## Code shape
- `ai-memory-workstream/src/jail.rs` (new): pure, OS-aware, dependency-injected
detection + command construction — `ai_jail_installed(lookup)`,
detection + command construction — `usable_ai_jail(os, lookup)`,
`inside_ai_jail(env, hostname)`, `build_ai_jail_invocation(exe, args, env_names)`.
Pure functions so the OS branches and the argv/env assembly are unit-tested
without a sandbox.
+13 -1
View File
@@ -662,7 +662,19 @@ On a normal exit, ai-memory imports the transcript and closes the lease before
returning. Handled setup, launch, or import failures cancel the lease
immediately. A new launch retries an active-workstream conflict briefly so a
previous launcher can finish; if another harness is genuinely still running,
the conflict remains and concurrent writers are still rejected. Terminal
the conflict remains and concurrent writers are still rejected.
A launcher that dies without releasing its lease — killed, its terminal
closed, or a sandbox such as ai-jail torn down — leaves the workstream held
until that lease lapses. An interactive relaunch (stdin and stderr are
terminals) no longer fails on that: the conflict reports the lease's expiry, so
ai-memory says who holds it and waits for it to lapse (at most one lease,
~90 seconds; `Ctrl+C` aborts), then starts normally. If the holder renews the
lease while you wait, it is a launcher that is still running, and you get an
error instead — stop it, or pass `--new <name>` for a separate workstream. The
server's busy check stays the only arbiter: the waiting launcher never forces
another run off. Non-interactive launches (scripts, hooks, CI) keep the short
retry window and fail fast rather than hanging. Terminal
interrupts continue to reach the child while the parent stays alive to finish
or cancel the run.
+1
View File
@@ -56,6 +56,7 @@ boundary not yet built.
| 11d | Hook server-profile routing: a marker-selected server gets only its own capture and its own token (#992) | `ai-memory-cli/src/server_profiles.rs` `resolve` (validated `ProfileName`, strict `servers.toml` parse, `roots` required once two profiles exist, component-wise root match) and `marker.rs` `find_server_selection` (inherited down the tree, any value shape counts); `commands/hook.rs` `resolve_hook_route` drops a `Rejected` route before spool, handoff and backfill, and hands the drainer no live token for a profile route; `commands/hook_spool.rs` `static_retry_token` (a profile entry retries only with its own stored token), the loopback reroot skip, and chunk splitting on `profile`; generated TS `captureServerRouted` drops a routed repository and gates `fetchHandoff`; `hooks/_lib.sh` `ai_memory_server_routed` (flag refused by `ai_memory_post_hook`/`ai_memory_get_handoff`) and `hooks/lib/ai-memory-hook.ps1` `Test-AiMemoryServerRouted` drop it in the script hooks | `hook.rs` `each_repository_spools_to_its_own_profile_with_its_own_token`, `a_selection_that_does_not_resolve_emits_nothing` (unknown / tokenless / outside roots / roots required / invalid name, plus a resolving control), `session_start_handoff_comes_from_the_profile_server_only`, `a_repository_without_a_server_key_keeps_the_install_default`; `hook_spool.rs` `a_profile_entry_is_never_retried_with_the_install_live_token` (server B accepts exactly the install's live token and must still not get it), `a_profile_entry_recovers_with_its_own_rotated_token` (control), `a_profile_entry_on_a_dead_loopback_port_is_not_rerouted_to_the_default`, `profile_and_default_entries_at_one_address_ride_separate_batches`; `server_profiles.rs` roots/registry/name tests; `marker.rs` `nested_markers_without_server_inherit_the_ancestor_selection`; `install_hooks.rs` `generated_integrations_fail_closed_on_a_server_profile_marker`, `openclaw_plugin.rs` `openclaw_plugin_fails_closed_on_a_server_profile_marker`, and the `server-routed-*` checks in `generated_capture_policy_v1_node_runtime_evidence`; `hook.rs` `an_event_without_a_payload_cwd_routes_by_the_process_cwd`, `a_refused_route_prints_nothing_for_kimi_user_prompts`; `hook_spool.rs` `a_profile_entry_is_not_retried_with_a_token_issued_for_a_new_url`; `server_profiles.rs` `changing_the_url_without_a_token_discards_the_old_token`, `omitted_roots_keep_the_registered_ones`; `marker.rs` `outside_home_the_walk_reaches_a_marker_above_the_checkout_root`, `encoding_noise_cannot_hide_a_server_key`, `an_unreadable_marker_is_a_refused_selection`; `backfill.rs` `a_spawned_backfill_authenticates_like_the_hook_that_spawned_it`; `tests/hooks/test_lib.sh` "server profiles (#992)" section; `hook.rs` `a_mixed_spool_drains_each_event_only_to_its_own_server` (two token-gated servers, one spool, one drain: each server receives exactly its own event with exactly its own bearer); `tests/suite/server_profiles.rs` (the built binary: `server add` → `hook` spools to the profile with its token, unknown profile spools nothing, `uninstall` removes the tokens; `two_real_servers_each_receive_only_their_own_repository` runs two real `ai-memory serve` children with different root tokens and checks on each server which repository landed there); `ai-memory-hooks` `powershell_server_routed.rs` `server_routed_guard_mirrors_the_native_walk` (runs `Test-AiMemoryServerRouted` under real PowerShell: inherited, BOM, look-alike keys, the `$HOME` boundary with its control, past a checkout root outside home, current directory) | STRONG for native hooks, generated TS, `.sh` and `.ps1` hooks. Known gap: an older binary draining a shared spool ignores `profile` |
| 12 | Network/auth posture | `config.rs` loopback `DEFAULT_BIND`; `serve.rs` `validate_http_exposure`, `require_allowed_host`; `auth.rs` `require_bearer` | `serve.rs` host-guard (missing→400 / forged→403), non-loopback-requires-token; `auth.rs` wrong-token 401 | STRONG |
| 13 | Managed-run transcript attribution (concurrent launches in one checkout, invariant #16) | `ai-memory-store/src/workstream.rs` `link_native_session` stamps `native_session_linked_at` on its own run only, both `finish` updates drop the stamp when the session changes, `run_status` reports it; `ai-memory-cli/src/commands/run.rs` `resolve_native_session_after_run` takes a linked session only when `ai-memory-workstream` `native_session_in_checkout` holds it for this checkout (OpenCode by recorded directory), never falls back to a link it set aside, and turns an `AmbiguousNativeSession` into a warning with nothing imported; `ai-memory-workstream/src/transcript.rs` `discover_crush` claims only the one top-level session created (or, with `--continue`, touched) during the run, and in a data directory outside the project only one that edited a file in it | `multi_session.rs` `a_session_linked_by_one_managed_run_is_not_another_runs`; `run.rs` `a_session_linked_during_the_run_wins_over_discovery` (concurrent newer session, another checkout's link refused, no fallback to it, unlinked control); `transcript.rs` `native_session_in_checkout_checks_the_opencode_directory`; `store/src/lib.rs` `managed_run_status_reports_a_link_made_during_the_run`; `run.rs` `ambiguous_crush_discovery_keeps_the_run`; `transcript.rs` `crush_discovery_claims_only_the_session_the_run_created`, `crush_discovery_in_a_shared_store_claims_only_an_edit_here` | PARTIAL: a run whose child links nothing still falls back to discovering the newest session in the checkout; Crush, which has no hooks, relies on that discovery alone and claims nothing when it is ambiguous |
| 13b | Managed-run lease exclusivity (one active run per workstream, invariant #16) | `ai-memory-store/src/workstream.rs` `prepare_run` expires lapsed leases and refuses any other `active` run on the workstream inside one transaction (`StoreError::WorkstreamBusy`), regardless of the `lease_owner` label; `heartbeat` renews only `active` rows. `ai-memory-cli/src/commands/run.rs` `wait_out_held_lease` (interactive relaunch) only waits for a reported expiry and retries — it never cancels or claims another run, so the server's busy check stays the sole arbiter | `store/src/lib.rs` `managed_workstream_batches_are_idempotent_and_release_the_lease` (second prepare refused while active); `run.rs` `a_renewed_lease_is_reported_as_a_live_owner_not_taken_over` (renewing holder is reported, never displaced), with controls `interactive_launch_waits_out_a_lapsing_lease_then_proceeds` and `ctrl_c_aborts_the_held_lease_wait_immediately` | STRONG for exclusivity. The `lease_owner` label (`host:pid`) is informational only and not unique inside ai-jail (every jailed launcher reports `ai-sandbox:<ns-pid>`), so it must never become an ownership key |
| 14 | Per-project authorization (#708) | `ai-memory-store/src/project_authz.rs` `authorize_project` / `ProjectAuthz::authorize` choke point (V68 `project_grants` + `projects.access_mode`, default `open`; V69 `projects.created_by` feeds `is_creator`); `scope.rs` `ScopeResolver::with_project_authz` (reader pool for reads, writer actor for writes) and its free forms `authorize_scope_for` / `*_guarded`, attached for every DB user by `ai-memory-mcp` `scope_resolver_as`, `ai-memory-web` `authorize_read` / `lookup_project`, and `ai-memory-hooks` (`grants.rs` `authorize_resolved` for run/workstream ids, the capture check in `router.rs`); read-shaped mutations resolve at `ProjectAccess::Write` (`resolve_existing_args(.., need)`); unscoped reads filtered before `LIMIT` by `reader.rs` `readable_repository_sql`; `WriterHandle::authorize_project` as defense in depth. Page ids are never taken from a caller (only derived from already-authorized hits), so there is no page-id entry point to guard | `tests/suite/project_authz.rs` (decision matrix, ship-inert, resolver gate); `tests/suite/access_mode.rs` `every_caller_against_both_modes`, `a_restricted_project_admits_the_team_and_refuses_the_outsider`, `new_projects_follow_the_server_default_and_admit_their_creator`; `scope.rs` `the_argument_shape_does_not_decide_the_level`, `a_user_reaches_only_what_they_were_granted`, `creating_authorizes_against_a_project_that_already_exists`, `a_refused_scope_fails_the_search_instead_of_shortening_it`; `grants.rs` `search_finds_only_what_the_viewer_may_read`, `the_limit_counts_only_what_the_viewer_may_see`, `the_workspace_handoff_comes_only_from_readable_repositories`; `ai-memory-mcp` `server.rs` `a_reader_may_read_everything_and_change_nothing`, `bob_cannot_read_alices_page_in_a_restricted_project`, `bob_cannot_find_alices_page_by_searching_in_a_restricted_project`, `bob_cannot_consolidate_a_session_in_alices_repository`, `a_restricted_projects_queues_need_write`; `ai-memory-hooks` `a_capture_needs_writer_on_the_repository_it_lands_in`, `session_start_delivers_nothing_from_a_repository_the_viewer_cannot_read`, `run_and_workstream_ids_only_answer_someone_who_may_reach_the_repository`; `ai-memory-web` `web_reads_honour_grants_in_a_restricted_project`, `metadata_shows_only_what_the_viewer_may_read` — each with a granted or open-project control, and proven to fail with the choke point (18 tests) or the SQL filter (9 tests) neutralized | STRONG — slice 3 closed both bypass classes (unscoped reads, raw-id entry points) and added the root-only management surface. Out of scope by design: per-project administrators (granting/restricting is root-only), and access modes, creators and grants live only in SQLite, so `reindex` resets them |
| 14b | Repository identity routing (#708) | `ai-memory-store/src/ops.rs` `resolve_project_by_identity` — one transaction; an identity already on a project is never overwritten; an unclaimed project is claimed only when the capturing user may write it (the choke point's `resolve_project_authz` on the same transaction), otherwise it is returned unclaimed; a different identity under the same name splits into a new project with no shared `repo_path`; `ai-memory-hooks/src/router.rs` `cache_key_for` keys the path cache by identity too; `ai-memory-core/src/repository_identity.rs` strips credentials from remote URLs client-side, and the server accepts only the routing rungs (`explicit`, `git_remote`) from the wire | `tests/suite/identity_resolution.rs` `an_outsider_cannot_take_an_unclaimed_projects_identity` (control: `an_existing_project_is_claimed_in_place`), `two_unrelated_repositories_with_one_folder_name_stay_apart`, `a_created_or_split_project_admits_its_creator`; `router.rs` `one_path_with_two_remotes_is_two_projects`, `two_api_checkouts_with_different_remotes_get_two_projects` (a manifest rung on the wire is ignored); `repository_identity.rs` credential and normalisation tables — the claim guard and the cache key each proven to fail their test when removed | STRONG — first claimant wins: in a restricted workspace a user who creates a project under a remote identity first holds it until root grants others |
| 14c | Client-supplied event time is bounded and self-scoped (#919) | `ai-memory-hooks/src/payload.rs` `HookEnvelope::occurred_at_micros` — a `/hook` caller's optional `occurred_at` must be `> 0` and `<= now + 5min`; it only sets the caller's *own* admitted session's `started_at`/`ended_at`/`created_at`, never another session/project/user, and the ingest dedup `seen_at`/TTL stays on `now` | `payload.rs` `occurred_at_micros_rejects_a_far_future_timestamp`, `…_rejects_non_positive_values`, `…_rejects_garbage_strings` | MEDIUM — self-scoped numeric bound; no cross-tenant surface (a client already controls its own content) |
+1 -1
View File
@@ -22,7 +22,7 @@
| Pi | Supported | Generated `~/.pi/agent/extensions/ai-memory-pi.ts` extension provides lifecycle capture and an HTTP MCP bridge; generated extension enforces capture exclusions. |
| Crush | Managed-only | `ai-memory run crush` resumes its project-local session database and supplies portable context through a temporary supported global-context file; no lifecycle-hook installer is provided. |
| Managed workstreams | Opt-in | `ai-memory run` provides transparent cross-harness continuity for Claude Code, Codex, OpenCode, OpenCode 2 beta, Pi, Crush, Kimi Code, Command Code, both incompatible Kiro CLI engines, OMP, Grok Build CLI, and Antigravity CLI. Direct launches remain unchanged. See [`docs/managed-workstreams.md`](managed-workstreams.md). |
| `--yolo` safety + ai-jail | Linux/macOS; Windows partial | `ai-memory run --yolo` warns before it disarms an agent's permission prompts and, on Linux/macOS, offers to re-exec the session inside [ai-jail](https://github.com/akitaonrails/ai-jail) when it is installed (`--network`, forwarding only already-set credential/config env vars). Both prompts are skipped on a non-interactive stdin/stderr and when already detected inside ai-jail. Windows gets the warning but never the ai-jail offer (ai-jail is Linux/macOS-only). Opt-in `--true-yolo` / `claude_true_yolo` additionally silences Claude Code's residual `rm`-prompt env vars and forces `bypassPermissions`, on every OS, Claude only. See [`docs/design-yolo-safety-ai-jail.md`](design-yolo-safety-ai-jail.md). |
| `--yolo` safety + ai-jail | Linux/macOS; Windows partial | `ai-memory run --yolo` warns before it disarms an agent's permission prompts and, on Linux/macOS, offers to re-exec the session inside [ai-jail](https://github.com/akitaonrails/ai-jail) when it is usable — installed and with its sandbox backend present (`bwrap` / `sandbox-exec`); otherwise it asks nothing and proceeds (`--network`, forwarding only already-set credential/config env vars). Both prompts are skipped on a non-interactive stdin/stderr and when already detected inside ai-jail. Windows gets the warning but never the ai-jail offer (ai-jail is Linux/macOS-only). Opt-in `--true-yolo` implies `--yolo` and, for Claude only, additionally forces `bypassPermissions` over any settings `defaultMode` (interchangeable with `--yolo` for other harnesses); `claude_true_yolo` applies the same Claude extra to `--yolo` launches. Claude's own explicit `ask` rules and command-safety checks still prompt in every mode. See [`docs/design-yolo-safety-ai-jail.md`](design-yolo-safety-ai-jail.md). |
| Claude Desktop | MCP-only | Uses `mcp-remote`; no lifecycle hooks. |
| OpenClaw | Supported | MCP config + native plugin lifecycle hooks; generated plugin enforces capture exclusions. |
| Antigravity CLI | Supported | MCP config (`serverUrl`) + lifecycle hooks (`agy` alias). Only `PreInvocation` with `invocationNum = 0` maps to SessionStart; later model calls cannot consume a next-session handoff. No automatic true session-end hook, so run `ai-memory finalize-session --agent antigravity-cli` after the final turn when you need a summary, handoff, and opt-in SessionEnd consolidation; an interactive session launched with `ai-memory run antigravity` is finalized automatically when it exits if the run can tie the session to itself (see [`docs/managed-workstreams.md`](managed-workstreams.md)). `ai-memory run antigravity` (aliases `antigravity-cli`, `agy`) adds managed workstream resume via `--conversation`; conversation text is not decoded, so the ledger for this harness comes from hook capture. Tool outputs (`run_command`, `view_file`, `list_dir`, etc.) are resolved directly from `.system_generated/steps/<stepIdx>/output.txt` during `post-tool-use` while mutation tools preserve code from arguments (#966). |