mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
Merge main into #1006 (resolve CHANGELOG against the stamped [2.5.1])
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -353,9 +353,8 @@ jobs:
|
||||
cargo audit
|
||||
--ignore RUSTSEC-2025-0141
|
||||
--ignore RUSTSEC-2024-0320
|
||||
--ignore RUSTSEC-2026-0194
|
||||
--ignore RUSTSEC-2026-0195
|
||||
- run: cargo audit --file companions/ai-memory-relay/Cargo.lock
|
||||
- run: cargo audit --file companions/ai-memory-importer/Cargo.lock
|
||||
|
||||
gitleaks:
|
||||
name: gitleaks (secret scan)
|
||||
|
||||
+60
-8
@@ -8,14 +8,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- A wiki checkpoint now leaves `.git/index` matching the commit it made. A
|
||||
- Fixed a wiki checkpoint leaving `.git/index` behind the commit it made. A
|
||||
path-scoped checkpoint wrote the index file only once every 50 commits, so
|
||||
between writes `HEAD` and the working tree held the new page while the
|
||||
index file still named the old blob, and `git status` from outside the
|
||||
server showed every checkpointed page as `MM` (staged and unstaged changes
|
||||
that cancel out). A checkpoint that found nothing to commit left a stale
|
||||
index file in place the same way. The history itself was always correct.
|
||||
Not specific to Windows. (#983, #1006)
|
||||
between writes `HEAD` and the working tree held the new page while the index
|
||||
still named the old blob, and `git status` from outside the server showed
|
||||
every checkpointed page as `MM`; a checkpoint with nothing to commit left a
|
||||
stale index the same way. The history itself was always correct. Not specific
|
||||
to Windows. (#983, #1006)
|
||||
|
||||
## [2.5.1] - 2026-10-01
|
||||
|
||||
### 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
|
||||
Windows UNC path regardless of the actual host, so it matched zero POSIX
|
||||
`ignore_paths` patterns (a flavor mismatch) and was captured instead of
|
||||
dropped. Path flavor for an untrusted candidate is now derived from the
|
||||
host (the cwd) rather than the candidate string alone, both in the native
|
||||
hook (`ai-memory-hooks` `capture_policy.rs`) and the generated
|
||||
OpenCode/OMP/Pi/OpenClaw TypeScript integrations
|
||||
(`ai-memory-cli` `render_shared.rs`); a genuine Windows/UNC host's UNC
|
||||
candidates are unaffected.
|
||||
|
||||
## [2.5.0] - 2026-09-30
|
||||
|
||||
@@ -7535,7 +7586,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Consolidator used server startup default project instead of the
|
||||
session's actual project.
|
||||
|
||||
[Unreleased]: https://github.com/akitaonrails/ai-memory/compare/v2.5.0...HEAD
|
||||
[Unreleased]: https://github.com/akitaonrails/ai-memory/compare/v2.5.1...HEAD
|
||||
[2.5.1]: https://github.com/akitaonrails/ai-memory/compare/v2.5.0...v2.5.1
|
||||
[2.5.0]: https://github.com/akitaonrails/ai-memory/compare/v2.4.2...v2.5.0
|
||||
[2.4.2]: https://github.com/akitaonrails/ai-memory/compare/v2.4.1...v2.4.2
|
||||
[2.4.1]: https://github.com/akitaonrails/ai-memory/compare/v2.4.0...v2.4.1
|
||||
|
||||
Generated
+12
-12
@@ -33,7 +33,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-cli"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-consolidate",
|
||||
"ai-memory-core",
|
||||
@@ -84,7 +84,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-consolidate"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-core",
|
||||
"ai-memory-llm",
|
||||
@@ -111,7 +111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-core"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"jiff",
|
||||
@@ -127,7 +127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-eval"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-consolidate",
|
||||
"ai-memory-core",
|
||||
@@ -150,7 +150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-hooks"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-consolidate",
|
||||
"ai-memory-core",
|
||||
@@ -175,7 +175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-llm"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-core",
|
||||
"anyhow",
|
||||
@@ -204,7 +204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-mcp"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-consolidate",
|
||||
"ai-memory-core",
|
||||
@@ -239,7 +239,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-store"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-core",
|
||||
"anyhow",
|
||||
@@ -265,11 +265,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-test-support"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-web"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-core",
|
||||
"ai-memory-store",
|
||||
@@ -292,7 +292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-wiki"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-core",
|
||||
"ai-memory-llm",
|
||||
@@ -324,7 +324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-memory-workstream"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"ai-memory-core",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ default-members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
|
||||
Generated
+4
-4
@@ -929,9 +929,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.41"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
@@ -965,9 +965,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -219,21 +219,35 @@ const CAPTURE_MAX_CALL_ID_CHARS = 128;
|
||||
type CaptureDisposition = "keep" | "drop" | "metadata-only";
|
||||
type CaptureProtocol = { version: 1; disposition: CaptureDisposition; policy_state: "inactive" | "active" | "invalid"; tool_family: "file" | "search-list" | "non-file" | "unknown"; path_count: number; extraction_state: "not-applicable" | "extracted" | "missing-or-malformed" | "unsupported-schema" };
|
||||
|
||||
type CaptureConfig = { state: "inactive" | "active" | "invalid"; patterns: { path: string; windows: boolean; directory?: string; prefix: string }[]; base: string };
|
||||
type CaptureConfig = { state: "inactive" | "active" | "invalid"; patterns: { path: string; windows: boolean; directory?: string; prefix: string }[]; base: string; windowsHost: boolean };
|
||||
function readFileSync(path: string, encoding?: "utf8"): any { if (encoding) return readMarkerText(path, encoding); const fd = openSync(path, "r"); try { const bytes = Buffer.allocUnsafe(CAPTURE_MARKER_MAX_BYTES + 1); const count = readSync(fd, bytes, 0, bytes.length, 0); if (count > CAPTURE_MARKER_MAX_BYTES) throw new Error("marker too large"); const result = bytes.subarray(0, count); new TextDecoder("utf-8", { fatal: true }).decode(result); return result; } finally { closeSync(fd); } }
|
||||
function captureTrimComment(line: string): string { let quote = ""; let escaped = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (escaped) { escaped = false; continue; } if (c === "\\" && quote === '"') { escaped = true; continue; } if ((c === '"' || c === "'") && (!quote || quote === c)) quote = quote ? "" : c; else if (c === "#" && !quote) { line = line.slice(0, i); break; } } if (line.trimStart().startsWith("[") && !/^\s*\[[^\]]+\]\s*$/.test(line)) throw new Error("invalid table header"); if (quote) throw new Error("unterminated string"); return line; }
|
||||
function captureNormalize(path: string): { path: string; windows: boolean } | undefined { const p = path.replace(/\\/g, "/"); let root: string; let tail: string[]; if (p.startsWith("//")) { const x = p.slice(2).split("/").filter(Boolean); if (x.length < 2) return undefined; root = `//${x.shift()}/${x.shift()}`; tail = x; } else if (/^[A-Za-z]:\//.test(p)) { root = `${p[0].toUpperCase()}:/`; tail = p.slice(3).split("/"); } else if (p.startsWith("/")) { root = "/"; tail = p.slice(1).split("/"); } else return undefined; const out: string[] = []; for (const x of tail) { if (!x || x === ".") continue; if (x === "..") out.pop(); else out.push(x); } return { path: root + (out.length ? (root.endsWith("/") ? "" : "/") + out.join("/") : ""), windows: root !== "/" }; }
|
||||
// `windowsHost` is omitted for self-determining callers (the cwd/marker base,
|
||||
// and ignore_paths patterns, which are allowed an explicit UNC/drive form
|
||||
// regardless of host). It is passed explicitly, from the already-resolved
|
||||
// host base, only when normalizing an untrusted tool-argument candidate: on a
|
||||
// POSIX host a leading `//` there is an ordinary doubled separator, not a UNC
|
||||
// root, and must collapse before flavor detection or it escapes every POSIX
|
||||
// `ignore_paths` pattern via a flavor mismatch (GHSA-vh98).
|
||||
function captureNormalize(path: string, windowsHost?: boolean): { path: string; windows: boolean } | undefined { const raw = windowsHost === false && path.startsWith("//") ? `/${path.replace(/^\/+/, "")}` : path; const p = raw.replace(/\\/g, "/"); let root: string; let tail: string[]; if (p.startsWith("//")) { const x = p.slice(2).split("/").filter(Boolean); if (x.length < 2) return undefined; root = `//${x.shift()}/${x.shift()}`; tail = x; } else if (/^[A-Za-z]:\//.test(p)) { root = `${p[0].toUpperCase()}:/`; tail = p.slice(3).split("/"); } else if (p.startsWith("/")) { root = "/"; tail = p.slice(1).split("/"); } else return undefined; const out: string[] = []; for (const x of tail) { if (!x || x === ".") continue; if (x === "..") out.pop(); else out.push(x); } return { path: root + (out.length ? (root.endsWith("/") ? "" : "/") + out.join("/") : ""), windows: root !== "/" }; }
|
||||
function captureJoin(base: string, child: string): string { if (/^[^A-Za-z]?:|^[A-Za-z]:[^/\\]/.test(child)) return child; return `${base.replace(/[\\/]+$/, "")}/${child}`; }
|
||||
function captureValidGlob(p: string): boolean { return !!p && [...p].length <= CAPTURE_MAX_PATTERN_CHARS && !/[!{}\[\]()|^$%]/.test(p) && !p.includes("${") && !p.includes("***") && !p.replace(/\\/g, "/").split("/").includes("..") && (!p.startsWith("~") || p.startsWith("~/")) && !/^[^A-Za-z]?:/.test(p) && !/^[A-Za-z]:[^/\\]/.test(p); }
|
||||
function captureParseArray(value: string): string[] | undefined { let i = 0; const out: string[] = []; const ws = () => { while (/\s/.test(value[i] ?? "")) i++; }; const basic = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" } as Record<string, string>; ws(); if (value[i++] !== "[") return undefined; for (;;) { ws(); if (value[i] === "]") { i++; ws(); return i === value.length ? out : undefined; } const quote = value[i++]; if (quote !== '"' && quote !== "'") return undefined; let s = ""; for (;;) { if (i >= value.length) return undefined; const c = value[i++]; if (c === quote) break; if (c === "\\" && quote === '"') { const e = value[i++]; if (e in basic) s += basic[e]; else if (e === "u" || e === "U") { const count = e === "u" ? 4 : 8; const hex = value.slice(i, i + count); if (!new RegExp(`^[0-9A-Fa-f]{${count}}$`).test(hex)) return undefined; const n = Number.parseInt(hex, 16); if (n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff)) return undefined; s += String.fromCodePoint(n); i += count; } else return undefined; } else if (c === "\n" || c === "\r") return undefined; else s += c; } out.push(s); ws(); if (value[i] === ",") { i++; continue; } if (value[i] === "]") continue; return undefined; } }
|
||||
function captureHostWindows(path: string): boolean { return path.startsWith("\\\\") || path.startsWith("//") || /^[A-Za-z]:/.test(path); }
|
||||
function captureConfig(cwd: string | undefined): CaptureConfig {
|
||||
const marker = findMarker(cwd);
|
||||
const candidateBase = captureNormalize(cwd ? resolve(cwd) : "")?.path ?? "";
|
||||
if (!marker) return { state: "inactive", patterns: [], base: candidateBase };
|
||||
// Host flavor comes from the cwd STRING itself, never from `resolve(cwd)`:
|
||||
// `resolve` re-roots a drive/UNC-shaped cwd through the real OS's own path
|
||||
// module, which only agrees on the OS that actually owns that path. The
|
||||
// cwd the hook reports is already absolute and already names its own host,
|
||||
// exactly like the native hook's pure-string `flavor_of(cwd)`.
|
||||
const windowsHost = captureHostWindows(cwd ?? "");
|
||||
if (!marker) return { state: "inactive", patterns: [], base: candidateBase, windowsHost };
|
||||
try {
|
||||
const bytes = readFileSync(marker);
|
||||
const markerBase = captureNormalize(dirname(marker))?.path ?? candidateBase;
|
||||
if (bytes.byteLength > CAPTURE_MARKER_MAX_BYTES) return { state: "invalid", patterns: [], base: candidateBase };
|
||||
if (bytes.byteLength > CAPTURE_MARKER_MAX_BYTES) return { state: "invalid", patterns: [], base: candidateBase, windowsHost };
|
||||
let section = "";
|
||||
let value = "";
|
||||
let collecting = false;
|
||||
@@ -243,26 +257,26 @@ function captureConfig(cwd: string | undefined): CaptureConfig {
|
||||
if (!line) continue;
|
||||
const table = /^\[([^\]]+)\]$/.exec(line);
|
||||
if (table) {
|
||||
if (collecting) return { state: "invalid", patterns: [], base: candidateBase };
|
||||
if (collecting) return { state: "invalid", patterns: [], base: candidateBase, windowsHost };
|
||||
section = table[1];
|
||||
continue;
|
||||
}
|
||||
if (section !== "capture") continue;
|
||||
if (!seen) {
|
||||
const kv = /^([A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(line);
|
||||
if (!kv || kv[1] !== "ignore_paths") return { state: "invalid", patterns: [], base: candidateBase };
|
||||
if (!kv || kv[1] !== "ignore_paths") return { state: "invalid", patterns: [], base: candidateBase, windowsHost };
|
||||
seen = true;
|
||||
value = kv[2];
|
||||
collecting = !value.includes("]");
|
||||
} else if (collecting) {
|
||||
value += ` ${line}`;
|
||||
collecting = !value.includes("]");
|
||||
} else return { state: "invalid", patterns: [], base: candidateBase };
|
||||
} else return { state: "invalid", patterns: [], base: candidateBase, windowsHost };
|
||||
}
|
||||
if (collecting) return { state: "invalid", patterns: [], base: candidateBase };
|
||||
if (!seen) return { state: "inactive", patterns: [], base: candidateBase };
|
||||
if (collecting) return { state: "invalid", patterns: [], base: candidateBase, windowsHost };
|
||||
if (!seen) return { state: "inactive", patterns: [], base: candidateBase, windowsHost };
|
||||
const strings = captureParseArray(value);
|
||||
if (!strings || strings.length > CAPTURE_MAX_PATTERNS) return { state: "invalid", patterns: [], base: candidateBase };
|
||||
if (!strings || strings.length > CAPTURE_MAX_PATTERNS) return { state: "invalid", patterns: [], base: candidateBase, windowsHost };
|
||||
const home = homedir();
|
||||
const patterns = strings.map((source) => {
|
||||
if (!captureValidGlob(source)) return undefined;
|
||||
@@ -275,12 +289,12 @@ function captureConfig(cwd: string | undefined): CaptureConfig {
|
||||
if (!normalized) return undefined;
|
||||
return { path: normalized.path, windows: normalized.windows, directory: normalized.path.endsWith("/**") ? (normalized.path.slice(0, -3) || "/") : undefined, prefix: captureLiteralPrefix(normalized.path) };
|
||||
});
|
||||
if (patterns.some((p) => !p)) return { state: "invalid", patterns: [], base: candidateBase };
|
||||
if (patterns.some((p) => !p)) return { state: "invalid", patterns: [], base: candidateBase, windowsHost };
|
||||
return patterns.length
|
||||
? { state: "active", patterns: patterns as CaptureConfig["patterns"], base: candidateBase }
|
||||
: { state: "inactive", patterns: [], base: candidateBase };
|
||||
? { state: "active", patterns: patterns as CaptureConfig["patterns"], base: candidateBase, windowsHost }
|
||||
: { state: "inactive", patterns: [], base: candidateBase, windowsHost };
|
||||
} catch (_e) {
|
||||
return { state: "invalid", patterns: [], base: candidateBase };
|
||||
return { state: "invalid", patterns: [], base: candidateBase, windowsHost };
|
||||
}
|
||||
}
|
||||
function captureGlob(pattern: string, candidate: string, insensitive: boolean, budget: { work: number }): boolean | undefined { const p = [...pattern]; const c = [...candidate]; const previous = new Array<boolean>(p.length + 1).fill(false); previous[0] = true; for (let j = 1; j <= p.length; j++) previous[j] = p[j - 1] === "*" && p[j] !== "*" && previous[j - 1]; for (const ch of c) { const current = new Array<boolean>(p.length + 1).fill(false); for (let j = 1; j <= p.length; j++) { if (++budget.work > CAPTURE_MAX_WORK) return undefined; const x = p[j - 1]; current[j] = x === "*" && p[j] === "*" ? false : x === "*" && j >= 2 && p[j - 2] === "*" ? current[j - 2] || previous[j] : x === "*" ? current[j - 1] || (ch !== "/" && previous[j]) : x === "?" ? ch !== "/" && previous[j - 1] : captureCharEq(x, ch, insensitive) && previous[j - 1]; } for (let j = 0; j <= p.length; j++) previous[j] = current[j]; } return previous[p.length]; }
|
||||
@@ -299,7 +313,7 @@ function captureShellArguments(word: string): string[] { const out = word.starts
|
||||
// Lexical only, like the native hook: nothing is expanded or executed, so
|
||||
// variables, command substitution, and `cd` state are not followed. A tool's
|
||||
// own `workdir` replaces the event cwd for relative arguments.
|
||||
function captureMatchCommand(command: string | string[], config: CaptureConfig, workdir?: string): boolean | undefined { const budget = { work: 0 }; const home = homedir(); const base = workdir === undefined ? config.base : /^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(workdir) ? workdir : config.base && captureJoin(config.base, workdir); for (const word of captureShellWordList(command)) for (const argument of captureShellArguments(word)) { const expanded = argument.startsWith("~/") ? captureJoin(home, argument.slice(2)) : argument; if ([...expanded].length > CAPTURE_MAX_PATH_CHARS) continue; const absolute = /^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(expanded); if (!absolute && !base) continue; const candidate = captureNormalize(absolute ? expanded : captureJoin(base, expanded)); if (!candidate) continue; const glob = /[*?]/.test(candidate.path); for (const pattern of config.patterns) { if (candidate.windows !== pattern.windows) continue; const under = captureStartsWith(candidate.path, pattern.prefix, pattern.windows); if (!under && !glob) continue; if (under) { const directory = pattern.directory ? captureGlob(pattern.directory, candidate.path, pattern.windows, budget) : false; if (directory !== false) return directory; const match = captureGlob(pattern.path, candidate.path, pattern.windows, budget); if (match !== false) return match; } if (glob) { const reaches = captureGlobReaches(candidate.path, pattern.prefix, pattern.windows, budget); if (reaches !== false) return reaches; } } } return false; }
|
||||
function captureMatchCommand(command: string | string[], config: CaptureConfig, workdir?: string): boolean | undefined { const budget = { work: 0 }; const home = homedir(); const base = workdir === undefined ? config.base : /^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(workdir) ? workdir : config.base && captureJoin(config.base, workdir); for (const word of captureShellWordList(command)) for (const argument of captureShellArguments(word)) { const expanded = argument.startsWith("~/") ? captureJoin(home, argument.slice(2)) : argument; if ([...expanded].length > CAPTURE_MAX_PATH_CHARS) continue; const absolute = /^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(expanded); if (!absolute && !base) continue; const candidate = captureNormalize(absolute ? expanded : captureJoin(base, expanded), config.windowsHost); if (!candidate) continue; const glob = /[*?]/.test(candidate.path); for (const pattern of config.patterns) { if (candidate.windows !== pattern.windows) continue; const under = captureStartsWith(candidate.path, pattern.prefix, pattern.windows); if (!under && !glob) continue; if (under) { const directory = pattern.directory ? captureGlob(pattern.directory, candidate.path, pattern.windows, budget) : false; if (directory !== false) return directory; const match = captureGlob(pattern.path, candidate.path, pattern.windows, budget); if (match !== false) return match; } if (glob) { const reaches = captureGlobReaches(candidate.path, pattern.prefix, pattern.windows, budget); if (reaches !== false) return reaches; } } } return false; }
|
||||
function captureTool(payload: Record<string, unknown>): { family: CaptureProtocol["tool_family"]; paths?: string[]; extraction: CaptureProtocol["extraction_state"]; callID?: string; command?: string | string[]; shell?: boolean; workdir?: string } { const name = typeof payload.tool === "string" ? payload.tool.toLowerCase() : ""; const args = payload.args as Record<string, unknown> | undefined; const call = ["tool_use_id","toolUseId","tool_call_id","toolCallId","call_id","callId","callID"].map((k) => payload[k]).find((v): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,128}$/.test(v)); if (["search","grep","glob","find","list","ls","list_files","read_dir","list_dir","grep_search","search_files","find_by_name"].includes(name)) return { family: "search-list", extraction: "not-applicable", callID: call }; if (["bash","shell","shell_command","exec","execute","run_command","web_search","search_web","manage_task","manage_subagents","terminal","execute_bash","execute_cmd"].includes(name)) return { family: "non-file", extraction: "extracted", callID: call, command: captureShellCommand(args), shell: name !== "web_search", workdir: typeof args?.workdir === "string" && args.workdir.trim() ? args.workdir : undefined }; if (!["read","write","edit","apply_patch","notebookedit","notebook_edit","create_file","delete_file","rename_file","move_file","multi_edit","multiedit","replace","replace_all"].includes(name)) return { family: "unknown", extraction: "extracted", callID: call }; const direct = (o: any): string[] | undefined => { if (!o || typeof o !== "object") return undefined; const r: string[] = []; for (const k of ["file_path","filePath","path","absolute_path","AbsolutePath","notebook_path","TargetFile"]) if (k in o) { if (typeof o[k] !== "string") return undefined; r.push(o[k]); } if ("paths" in o) { if (!Array.isArray(o.paths) || o.paths.some((x: unknown) => typeof x !== "string")) return undefined; r.push(...o.paths); } return r.length && r.length <= CAPTURE_MAX_CANDIDATES ? r : undefined; }; let paths = direct(args); if (["multi_edit","multiedit","replace_all"].includes(name)) { const entries = args?.edits ?? args?.replacements; if (!Array.isArray(entries) || !entries.length || entries.length > CAPTURE_MAX_CANDIDATES) paths = undefined; else { paths = paths ?? []; for (const entry of entries) { const more = direct(entry); if (!more || paths.length + more.length > CAPTURE_MAX_CANDIDATES) { paths = undefined; break; } paths.push(...more); } } } if (!paths || paths.some((p) => !p.trim() || [...p].length > CAPTURE_MAX_PATH_CHARS)) return { family: "file", extraction: "missing-or-malformed", callID: call }; return { family: "file", paths, extraction: "extracted", callID: call }; }
|
||||
// An external lifecycle owner (`AI_MEMORY_CAPTURE_OWNER`, any value that is
|
||||
// non-empty after trimming) takes over capture for this process: the gate runs
|
||||
@@ -316,7 +330,7 @@ function captureOwnedExternally(): boolean { const owner = typeof process === "u
|
||||
// home the walk stops at home, outside it continues past the checkout root to an
|
||||
// organisation-level marker; an unreadable marker counts as a selection.
|
||||
function captureServerRouted(cwd: string | undefined): boolean { let dir = resolve(cwd ?? process.cwd()); const home = homedir(); let boundary: string | undefined; for (let probe = dir; ; probe = dirname(probe)) { if (probe === home) { boundary = home; break; } if (probe === dirname(probe)) break; } for (;;) { try { if (/^\s*server\s*=/m.test(readFileSync(join(dir, ".ai-memory.toml"), "utf8"))) return true; } catch (e) { if (!["ENOENT", "ENOTDIR", "EISDIR"].includes((e as { code?: string })?.code ?? "")) return true; } if (dir === boundary || dir === dirname(dir)) return false; dir = dirname(dir); } }
|
||||
function capturePolicy(payload: Record<string, unknown>, cwd: string | undefined): { disposition: CaptureDisposition; protocol?: CaptureProtocol; payload: Record<string, unknown> } { if (captureOwnedExternally()) return { disposition: "drop", payload }; if (captureServerRouted(cwd)) return { disposition: "drop", payload }; const markerPresent = !!findMarker(cwd); if (CAPTURE_MODE === "allowlist" && !markerPresent) return { disposition: "drop", payload }; const config = captureConfig(cwd); const tool = captureTool(payload); let disposition: CaptureDisposition = "keep"; if (config.state === "invalid" && (tool.family === "file" || tool.shell)) disposition = "metadata-only"; else if (config.state === "active" && tool.family === "search-list") disposition = "drop"; else if (config.state === "active" && tool.family === "file") { if (!tool.paths) disposition = "metadata-only"; else { const candidates = tool.paths.map((p) => captureNormalize(/^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(p) ? p : captureJoin(config.base, p))); if (candidates.some((p) => !p)) disposition = "metadata-only"; else { const budget = { work: 0 }; captureMatch: for (const candidate of candidates as { path: string; windows: boolean }[]) for (const pattern of config.patterns) { if (candidate.windows !== pattern.windows) continue; if (pattern.directory && captureGlob(pattern.directory, candidate.path, pattern.windows, budget)) { disposition = "drop"; break captureMatch; } const match = captureGlob(pattern.path, candidate.path, pattern.windows, budget); if (match === undefined) { disposition = "metadata-only"; break; } if (match) { disposition = "drop"; break captureMatch; } } } } } else if (config.state === "active" && tool.family === "non-file" && tool.command !== undefined && captureMatchCommand(tool.command, config, tool.workdir) !== false) disposition = "drop"; if (config.state === "inactive") return { disposition, payload }; const protocol: CaptureProtocol = { version: CAPTURE_POLICY_V1, disposition, policy_state: config.state, tool_family: tool.family, path_count: tool.paths?.length ?? 0, extraction_state: tool.extraction }; if (disposition === "metadata-only") { const session = payload.sessionID ?? payload.sessionId ?? payload.session_id; const routing = typeof payload.cwd === "string" ? payload.cwd : cwd; return { disposition, protocol, payload: { ...(typeof session === "string" ? { session_id: session } : {}), ...(typeof routing === "string" ? { cwd: routing } : {}), tool_family: tool.family, tool_name: tool.family, ...(tool.callID ? { tool_call_id: tool.callID } : {}), _ai_memory_capture: protocol } }; } if (disposition === "keep") return { disposition, protocol, payload: { ...payload, _ai_memory_capture: protocol } }; return { disposition, protocol, payload }; }
|
||||
function capturePolicy(payload: Record<string, unknown>, cwd: string | undefined): { disposition: CaptureDisposition; protocol?: CaptureProtocol; payload: Record<string, unknown> } { if (captureOwnedExternally()) return { disposition: "drop", payload }; if (captureServerRouted(cwd)) return { disposition: "drop", payload }; const markerPresent = !!findMarker(cwd); if (CAPTURE_MODE === "allowlist" && !markerPresent) return { disposition: "drop", payload }; const config = captureConfig(cwd); const tool = captureTool(payload); let disposition: CaptureDisposition = "keep"; if (config.state === "invalid" && (tool.family === "file" || tool.shell)) disposition = "metadata-only"; else if (config.state === "active" && tool.family === "search-list") disposition = "drop"; else if (config.state === "active" && tool.family === "file") { if (!tool.paths) disposition = "metadata-only"; else { const candidates = tool.paths.map((p) => captureNormalize(/^(?:\/|\\\\|[A-Za-z]:[\\/])/.test(p) ? p : captureJoin(config.base, p), config.windowsHost)); if (candidates.some((p) => !p)) disposition = "metadata-only"; else { const budget = { work: 0 }; captureMatch: for (const candidate of candidates as { path: string; windows: boolean }[]) for (const pattern of config.patterns) { if (candidate.windows !== pattern.windows) continue; if (pattern.directory && captureGlob(pattern.directory, candidate.path, pattern.windows, budget)) { disposition = "drop"; break captureMatch; } const match = captureGlob(pattern.path, candidate.path, pattern.windows, budget); if (match === undefined) { disposition = "metadata-only"; break; } if (match) { disposition = "drop"; break captureMatch; } } } } } else if (config.state === "active" && tool.family === "non-file" && tool.command !== undefined && captureMatchCommand(tool.command, config, tool.workdir) !== false) disposition = "drop"; if (config.state === "inactive") return { disposition, payload }; const protocol: CaptureProtocol = { version: CAPTURE_POLICY_V1, disposition, policy_state: config.state, tool_family: tool.family, path_count: tool.paths?.length ?? 0, extraction_state: tool.extraction }; if (disposition === "metadata-only") { const session = payload.sessionID ?? payload.sessionId ?? payload.session_id; const routing = typeof payload.cwd === "string" ? payload.cwd : cwd; return { disposition, protocol, payload: { ...(typeof session === "string" ? { session_id: session } : {}), ...(typeof routing === "string" ? { cwd: routing } : {}), tool_family: tool.family, tool_name: tool.family, ...(tool.callID ? { tool_call_id: tool.callID } : {}), _ai_memory_capture: protocol } }; } if (disposition === "keep") return { disposition, protocol, payload: { ...payload, _ai_memory_capture: protocol } }; return { disposition, protocol, payload }; }
|
||||
"##;
|
||||
TEMPLATE.replace("__AI_MEMORY_CAPTURE_MODE__", capture_mode)
|
||||
}
|
||||
@@ -2311,7 +2325,17 @@ for (const vector of fixture.decisions.filter((v: any) => ["open-code", "omp", "
|
||||
}}
|
||||
for (const vector of fixture.normalization) {{
|
||||
const cwd = marker(`[capture]\nignore_paths = [${{JSON.stringify(vector.pattern)}}]\n`);
|
||||
expectDecision({{ tool: "edit", args: {{ path: vector.candidate }} }}, cwd, vector.match ? "drop" : "keep", "file", "extracted", 1, "fixture-normalization");
|
||||
// Every normalization vector here uses an absolute pattern+candidate pair
|
||||
// (so neither ever needs the real marker directory as a join base); the
|
||||
// fixture's own `cwd` is what decides host flavor. A Windows-shaped `cwd`
|
||||
// (e.g. "C:/") can't be a real directory on this (POSIX) test runner —
|
||||
// `resolve("C:/")` would mangle it through the real OS path module — so
|
||||
// alias the already-written marker file under the fixture's literal `cwd`
|
||||
// string and pass that string straight through, exactly like the native
|
||||
// test honors `vector["cwd"]` verbatim.
|
||||
const hostCwd: string = /^(?:\\\\|\/\/|[A-Za-z]:)/.test(vector.cwd) ? vector.cwd : cwd;
|
||||
if (hostCwd !== cwd) markerFixtures.set(hostCwd, markerFixtures.get(cwd)!);
|
||||
expectDecision({{ tool: "edit", args: {{ path: vector.candidate }} }}, hostCwd, vector.match ? "drop" : "keep", "file", "extracted", 1, "fixture-normalization");
|
||||
}}
|
||||
expectDecision({{ tool: "edit", args: {{ path: "private/item" }} }}, marker('[capture]\nignore_paths = ["private/**"]\n'), "drop", "file", "extracted", 1, "marker-relative");
|
||||
// Shell parity with the native hook's lexical command matching (#948): the
|
||||
@@ -2345,7 +2369,8 @@ expectDecision({{ tool: "edit", args: {{ path: "private/item" }} }}, nestedCwd,
|
||||
expectDecision({{ tool: "edit", args: {{ path: `${{homedir()}}/home-private/item` }} }}, marker('[capture]\nignore_paths = ["~/home-private/**"]\n'), "drop", "file", "extracted", 1, "home-expansion");
|
||||
expectDecision({{ tool: "edit", args: {{ path: "case/item" }} }}, marker('[capture]\nignore_paths = ["Case/**"]\n'), "keep", "file", "extracted", 1, "posix-case");
|
||||
expectDecision({{ tool: "edit", args: {{ path: "c:/SECRET/item" }} }}, marker('[capture]\nignore_paths = ["C:/secret/**"]\n'), "drop", "file", "extracted", 1, "windows-drive-case");
|
||||
expectDecision({{ tool: "edit", args: {{ path: "//SERVER/SHARE/item" }} }}, marker(`[capture]\nignore_paths = ['${{String.raw`\\server\share/**`}}']\n`), "drop", "file", "extracted", 1, "windows-unc-case");
|
||||
const uncCwd = "C:/"; markerFixtures.set(uncCwd, markerFixtures.get(marker(`[capture]\nignore_paths = ['${{String.raw`\\server\share/**`}}']\n`))!);
|
||||
expectDecision({{ tool: "edit", args: {{ path: "//SERVER/SHARE/item" }} }}, uncCwd, "drop", "file", "extracted", 1, "windows-unc-case");
|
||||
expectDecision({{ tool: "edit", args: {{ path: "x" }} }}, marker('[capture'), "metadata-only", "file", "extracted", 1, "malformed-table");
|
||||
const malformedQuoteCwd = marker('[capture]\nignore_paths = ["private/**');
|
||||
expectDecision({{ tool: "edit", args: {{ path: privatePath }} }}, malformedQuoteCwd, "metadata-only", "file", "extracted", 1, "malformed-unterminated-quote");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1014,11 +1014,24 @@ fn normalize_candidate(candidate: &str, cwd: &str) -> Option<Normalized> {
|
||||
return None;
|
||||
}
|
||||
let cwd = normalize_root(cwd).ok()?;
|
||||
// Flavor must come from the host (the cwd), never from the candidate
|
||||
// string alone: on a POSIX host a leading `//` is an ordinary doubled
|
||||
// separator, not a UNC root, but `flavor_of` cannot tell the two apart
|
||||
// from the string in isolation. Collapsing it first keeps a POSIX
|
||||
// candidate POSIX-flavored so it still matches a POSIX `ignore_paths`
|
||||
// pattern instead of silently escaping every pattern via a flavor
|
||||
// mismatch (GHSA-vh98). A genuine Windows/UNC host is unaffected: the
|
||||
// collapse only runs when the cwd itself is not windows-flavored.
|
||||
let raw = if is_absolute(candidate) {
|
||||
candidate.to_owned()
|
||||
} else {
|
||||
join(&cwd, candidate)
|
||||
};
|
||||
let raw = if flavor_of(&cwd) == Flavor::Posix && raw.starts_with("//") {
|
||||
format!("/{}", raw.trim_start_matches('/'))
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let flavor = flavor_of(&raw);
|
||||
Some(Normalized {
|
||||
path: normalize_segments(&raw)?,
|
||||
@@ -1326,6 +1339,69 @@ mod tests {
|
||||
assert!(CaptureProtocol::parse(&fixture["protocol"]["accept"]).is_some());
|
||||
assert!(CaptureProtocol::parse(&fixture["protocol"]["reject"]).is_none());
|
||||
}
|
||||
/// Adversarial regression for GHSA-vh98 / security-boundaries.md row
|
||||
/// 11b: a POSIX-host candidate spelled with a leading `//` used to be
|
||||
/// classified `Flavor::Windows` purely from the string, so it matched
|
||||
/// zero POSIX `ignore_paths` patterns (flavor mismatch in `match_paths`'
|
||||
/// filter) and was captured instead of dropped. `flavor_of` cannot tell
|
||||
/// a doubled POSIX separator from a UNC root by itself; only the host
|
||||
/// (the cwd) can. This attempts the violation, proves a plain-looking
|
||||
/// control still drops normally, and proves a genuine Windows-hosted UNC
|
||||
/// candidate still matches (so the fix didn't just blanket-collapse
|
||||
/// every `//`).
|
||||
#[test]
|
||||
fn a_leading_double_slash_candidate_does_not_escape_posix_ignore_paths_via_flavor_mismatch() {
|
||||
let policy = CapturePolicy::resolve(
|
||||
CaptureSource::Parsed(&CaptureConfig {
|
||||
ignore_paths: vec!["secret/**".into()],
|
||||
}),
|
||||
"/repo",
|
||||
None,
|
||||
);
|
||||
// The violation attempt: on the unfixed code this normalized to a
|
||||
// Windows-flavored candidate and matched no POSIX pattern, so it
|
||||
// came back `Keep` (captured) instead of `Drop`.
|
||||
let attack =
|
||||
json!({"tool_name":"Edit","tool_input":{"file_path":"//repo/secret/token.txt"}});
|
||||
assert_eq!(
|
||||
policy
|
||||
.inspect(AgentKind::ClaudeCode, &attack, "/repo")
|
||||
.protocol()
|
||||
.disposition(),
|
||||
CaptureDisposition::Drop,
|
||||
"a leading `//` must not escape a POSIX host's ignore_paths"
|
||||
);
|
||||
// Legitimate control: an ordinary single-slash candidate under the
|
||||
// same pattern must keep being dropped.
|
||||
let control =
|
||||
json!({"tool_name":"Edit","tool_input":{"file_path":"/repo/secret/token.txt"}});
|
||||
assert_eq!(
|
||||
policy
|
||||
.inspect(AgentKind::ClaudeCode, &control, "/repo")
|
||||
.protocol()
|
||||
.disposition(),
|
||||
CaptureDisposition::Drop
|
||||
);
|
||||
// Windows-UNC control: a genuine UNC candidate on a Windows host
|
||||
// must still match a UNC pattern — the fix is host-derived, not an
|
||||
// unconditional `//` -> `/` collapse.
|
||||
let windows_policy = CapturePolicy::resolve(
|
||||
CaptureSource::Parsed(&CaptureConfig {
|
||||
ignore_paths: vec!["\\\\server\\share\\**".into()],
|
||||
}),
|
||||
"C:/",
|
||||
None,
|
||||
);
|
||||
let unc = json!({"tool_name":"Edit","tool_input":{"file_path":"//SERVER/SHARE/token.txt"}});
|
||||
assert_eq!(
|
||||
windows_policy
|
||||
.inspect(AgentKind::ClaudeCode, &unc, "C:/")
|
||||
.protocol()
|
||||
.disposition(),
|
||||
CaptureDisposition::Drop,
|
||||
"a genuine UNC candidate on a Windows host must still match"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn all_states_and_strict_protocol_are_reachable() {
|
||||
let file = json!({"tool_name":"Edit","tool_input":{}});
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
{"pattern":"unicode/?.txt","candidate":"unicode/é.txt","cwd":"/repo","match":true},
|
||||
{"pattern":"foo..bar","candidate":"foo..bar","cwd":"/repo","match":true},
|
||||
{"pattern":"docs/a?r/**","candidate":"docs/adr","cwd":"/repo","match":true},
|
||||
{"pattern":"docs/a?r/**","candidate":"docs/adrx","cwd":"/repo","match":false}
|
||||
{"pattern":"docs/a?r/**","candidate":"docs/adrx","cwd":"/repo","match":false},
|
||||
{"pattern":"/repo/secret/**","candidate":"//repo/secret/token.txt","cwd":"/repo","match":true},
|
||||
{"pattern":"/repo/secret/**","candidate":"//repo/secret","cwd":"/repo","match":true}
|
||||
],
|
||||
"decisions": [
|
||||
{"agent":"claude-code","payload":{"tool_name":"Edit","tool_input":{"file_path":"secret/a"}},"disposition":"drop","tool_family":"file","extraction_state":"extracted","path_count":1},
|
||||
@@ -43,7 +45,8 @@
|
||||
{"agent":"codex","payload":{"tool_name":"Search","tool_input":{}},"disposition":"drop","tool_family":"search-list","extraction_state":"not-applicable","path_count":0},
|
||||
{"agent":"codex","payload":{"tool_name":"Bash","tool_input":{"command":"secret/a"}},"disposition":"drop","tool_family":"non-file","extraction_state":"extracted","path_count":0},
|
||||
{"agent":"codex","payload":{"tool_name":"FutureTool","tool_input":{"path":"secret/a"}},"disposition":"keep","tool_family":"unknown","extraction_state":"extracted","path_count":0},
|
||||
{"agent":"codex","payload":{"tool_name":"Edit","tool_input":{"nested":{"path":"secret/a"}}},"disposition":"metadata-only","tool_family":"file","extraction_state":"missing-or-malformed","path_count":0}
|
||||
{"agent":"codex","payload":{"tool_name":"Edit","tool_input":{"nested":{"path":"secret/a"}}},"disposition":"metadata-only","tool_family":"file","extraction_state":"missing-or-malformed","path_count":0},
|
||||
{"agent":"claude-code","payload":{"tool_name":"Edit","tool_input":{"file_path":"//repo/secret/a"}},"disposition":"drop","tool_family":"file","extraction_state":"extracted","path_count":1}
|
||||
],
|
||||
"shell": {
|
||||
"note": "Shell-tool vectors run by both matchers. `{root}` is the marker directory, `cwd` is relative to it, and `command` is shorthand for a `bash` payload.",
|
||||
@@ -52,6 +55,7 @@
|
||||
{"command":"cat docs/adr/0001.md","disposition":"drop"},
|
||||
{"command":"cat ./docs/adr/0001.md","disposition":"drop"},
|
||||
{"command":"cat {root}/docs/adr/0001.md","disposition":"drop"},
|
||||
{"command":"cat /{root}/docs/adr/x.md","disposition":"drop"},
|
||||
{"command":"cat ../docs/adr/0001.md","cwd":"sub","disposition":"drop"},
|
||||
{"command":"cat docs/adr/*.md","disposition":"drop"},
|
||||
{"command":"cat docs/*/0001.md","disposition":"drop"},
|
||||
|
||||
@@ -3431,6 +3431,129 @@ async fn web_reads_honour_grants_in_a_restricted_project() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins a documented, by-design boundary (see `docs/users.md`): under a
|
||||
/// trusted-proxy deployment, a proxied non-root end-user is authenticated as
|
||||
/// [`ai_memory_core::AuthLevel::User`] with an [`ai_memory_core::ActorContext`]
|
||||
/// (`auth.rs::authenticate_token`'s proxy branch), but — unlike a database
|
||||
/// user — is never stamped with an [`ai_memory_core::AuthorizedViewer`],
|
||||
/// because grants are keyed on `UserId` and a proxied identity has none.
|
||||
/// `viewer_from_parts` reads a missing `AuthorizedViewer` as "no per-project
|
||||
/// check applies" (same as root, or an install with no database users), so
|
||||
/// the per-project authorization gate is a pass-through for this actor: a
|
||||
/// `restricted` project is readable with no grant at all. This is NOT an
|
||||
/// endorsement of a gap to close — the proxy is the authorization boundary in
|
||||
/// this deployment shape — it is a pin so a future change to this behavior is
|
||||
/// an intentional decision, not a silent regression.
|
||||
///
|
||||
/// This mirrors `web_reads_honour_grants_in_a_restricted_project`'s harness:
|
||||
/// that test's own `viewer: None` case already exercises the same code path
|
||||
/// (a missing `AuthorizedViewer`), but doesn't carry the extensions a real
|
||||
/// trusted-proxy request would, so it doesn't document *why* that is safe
|
||||
/// here. The real middleware (`authenticate_token`) is not reachable from
|
||||
/// this router-only harness, so this stamps the same extensions it would
|
||||
/// have stamped, by hand.
|
||||
#[tokio::test]
|
||||
async fn a_trusted_proxy_user_is_not_subject_to_restricted_without_a_db_identity() {
|
||||
use ai_memory_core::{ActorContext, AuthLevel};
|
||||
|
||||
let (_tmp, store, wiki) = setup().await;
|
||||
store
|
||||
.writer
|
||||
.set_new_project_mode(ai_memory_store::AccessMode::Restricted)
|
||||
.await
|
||||
.unwrap();
|
||||
let ws = store
|
||||
.writer
|
||||
.get_or_create_workspace("default")
|
||||
.await
|
||||
.unwrap();
|
||||
let client = store
|
||||
.writer
|
||||
.get_or_create_project(ws, "alice-client-work", None)
|
||||
.await
|
||||
.unwrap();
|
||||
wiki.write_page(wiki_req(
|
||||
ws,
|
||||
client,
|
||||
"secrets/rates.md",
|
||||
"# Rates\n\nDay rate is confidential.",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// A real database user, with no grant, is correctly refused — the
|
||||
// control proving the project really is restricted.
|
||||
let carol = store
|
||||
.writer
|
||||
.create_human_user(
|
||||
ai_memory_core::NewUser {
|
||||
username: "carol".into(),
|
||||
name: None,
|
||||
email: None,
|
||||
},
|
||||
ai_memory_core::UserRole::User,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let api = api_router(store.reader.clone(), wiki.clone());
|
||||
let web = router(store.reader.clone(), wiki.clone());
|
||||
let routes = [
|
||||
(
|
||||
api.clone(),
|
||||
"/workspaces/default/projects/alice-client-work/pages/secrets/rates.md",
|
||||
),
|
||||
(
|
||||
web.clone(),
|
||||
"/w/default/alice-client-work/p/secrets/rates.md",
|
||||
),
|
||||
];
|
||||
|
||||
// Control: carol as a *database* user (AuthorizedViewer stamped, no
|
||||
// grant) is refused on both surfaces.
|
||||
for (app, uri) in &routes {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(*uri)
|
||||
.extension(ai_memory_core::AuthorizedViewer(carol))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{uri}");
|
||||
}
|
||||
|
||||
// The pinned case: the same project, read by a request carrying the
|
||||
// extensions a trusted-proxy non-root user actually gets (`ActorContext`
|
||||
// + `AuthLevel::User`) and nothing else — no `AuthorizedViewer`. Today
|
||||
// this is admitted.
|
||||
for (app, uri) in &routes {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(*uri)
|
||||
.extension(ActorContext {
|
||||
user: Some("carol-proxied".into()),
|
||||
..ActorContext::default()
|
||||
})
|
||||
.extension(AuthLevel::User)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"{uri}: trusted-proxy user without a DB identity must be admitted today (pinned boundary)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #708, second half on the web: bob can no longer read or search alice's
|
||||
/// repositories, but every listing, the graph, the workspace overview and a
|
||||
/// page's link panel still told him they existed and roughly what was in
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -51,11 +51,12 @@ boundary not yet built.
|
||||
| 10e | Reconcile-tombstone of a disk-deleted page is opt-in, soft, scope-bound, and false-positive-guarded (invariant #16, #929 delete-half) | `ai-memory-wiki/src/watcher.rs` — off by default (`[maintenance] reconcile_tombstones_deleted_pages`); a page is tombstoned only after its file is observed missing on two consecutive passes (streak resets if the path's `PageId` changed), re-verified against the live filesystem immediately before acting, with reserved/indexed-but-unwalked paths (`bootstrap.md`, `_meta.md`, `_pending/`, `sessions/*.md`) excluded and a circuit breaker that skips a whole scope when `> max(3, 50%)` of its pages look missing or a non-partial walk finds nothing; `ai-memory-store/src/ops.rs` `soft_delete_for_reconcile_if_latest` is a soft tombstone (`is_latest=0` + `superseded_at`, decay's shape) through the single writer actor in one transaction — never a filesystem write — scoped to the page's own `(workspace_id, project_id)` | `watcher.rs` `reconcile_delete_disabled_by_default_never_tombstones_a_missing_page`, `reconcile_delete_cross_project_isolation` (project A's deletion never tombstones project B's same-path page), `reconcile_tombstone_refuses_a_stale_latest_id`, `reconcile_tombstone_refuses_when_the_file_still_exists`, `reconcile_tombstone_never_touches_the_filesystem`; `ops.rs` `reconcile_tombstone_resurrects_instead_of_orphaning_on_recreate` | STRONG |
|
||||
| 10f | Session-time repair is scope-bound (no cross-project timestamp rewrite) | `ai-memory-store/src/ops.rs` `repair_session_times` — every candidate is read back and updated with `WHERE id=? AND workspace_id=? AND project_id=?`; a row outside that 3-tuple is reported `NotFound` and untouched, same code path as an id that does not exist at all; `ai-memory-mcp/src/admin.rs` `handle_repair_session_times` passes the caller's `(workspace, project)` straight through, never the candidate's own | `ops.rs` `repair_session_times_does_not_touch_a_session_of_another_project` (control: sibling session in-scope repaired in the same batch); `admin_repair_session_times.rs` `cross_project_session_is_not_found_and_untouched`; both bite-checked by dropping the `project_id` filter | STRONG |
|
||||
| 11a | Hook backpressure (202/429) + bounded fan-out (invariant #5) | `ai-memory-hooks/src/router.rs` semaphore→429, 202 immediately, `MAX_HOOK_BATCH_ITEMS`, bounded LRU limiter | `router.rs` `handle_hook_returns_429_when_ingest_saturated`, `ingest_rate_limiter_is_bounded` | STRONG |
|
||||
| 11b | Capture exclusions drop before storage | `ai-memory-hooks` `capture_policy.rs` `inspect`→`Drop` (before semaphore/spawn), including shell commands whose arguments name an ignored path (`match_command`; argv elements matched whole and tokenized); an invalid marker makes file and shell calls metadata-only, and the server admits a metadata-only shell body only under an invalid marker (`router.rs` `metadata_protocol_is_legal`); generated OpenCode/OMP/Pi/OpenClaw integrations mirror it in `render_shared.rs` `ts_capture_policy_v1` (`captureMatchCommand`) | `capture_policy.rs` per-agent `…honors_exclusions` tests, `shell_fixture_vectors` (shared `capture-policy.json` `shell` drop/keep vectors: tool aliases incl. OpenClaw/Devin `exec`, `workdir`, glob directories, Windows paths), `shell_tool_shapes_of_every_adapter_honor_exclusions`, `fixture_vectors` (incl. TS-adapter `bash`/`exec` vectors); `shell_matching_is_off_when_inactive_and_fails_closed_when_invalid_or_over_budget`; `router.rs` `capture_protocol_shell_decisions_survive_server_reinspection`, `capture_protocol_invalid_marker_shell_is_metadata_only` (active metadata-only shell refused, older client's invalid-marker keep stripped, commandless control kept), `capture_protocol_unparseable_marker_strips_shell_events` (server fallback for an unparseable marker), `capture_protocol_invalid_shell_metadata_claim_must_be_canonical` (a stripped shell claim with a path count or non-`extracted` state is refused); `capture_policy.rs` `invalid_marker_strips_shell_calls_with_unparseable_commands`, `long_bash_lc_script_in_argv_is_not_dropped_by_the_match_budget`; `hook.rs` `shell_command_reading_an_ignored_path_is_dropped_before_spool`; `render_shared.rs` `generated_capture_policy_v1_node_runtime_evidence` (runs the same fixture sections against the emitted TypeScript; `#[ignore]` locally, run with `--ignored` under Node 24 by the Linux CI test job) | STRONG |
|
||||
| 11b | Capture exclusions drop before storage | `ai-memory-hooks` `capture_policy.rs` `inspect`→`Drop` (before semaphore/spawn), including shell commands whose arguments name an ignored path (`match_command`; argv elements matched whole and tokenized); an invalid marker makes file and shell calls metadata-only, and the server admits a metadata-only shell body only under an invalid marker (`router.rs` `metadata_protocol_is_legal`); generated OpenCode/OMP/Pi/OpenClaw integrations mirror it in `render_shared.rs` `ts_capture_policy_v1` (`captureMatchCommand`). Candidate/argument flavor (`Flavor::Posix` vs `Flavor::Windows`, used to pick which `ignore_paths` patterns even apply) is derived from the **host** (the cwd), never from the candidate string alone — a POSIX-host candidate spelled with a leading `//` is collapsed to a single `/` before flavor detection (`normalize_candidate`; TS `captureNormalize`'s `windowsHost` parameter, sourced from `captureHostWindows(cwd)`), fixing GHSA-vh98 (a `//`-prefixed candidate used to self-classify as `Flavor::Windows` regardless of host, matching zero POSIX patterns and being captured instead of dropped); a genuine Windows/UNC host (cwd itself windows-flavored) is unaffected | `capture_policy.rs` per-agent `…honors_exclusions` tests, `shell_fixture_vectors` (shared `capture-policy.json` `shell` drop/keep vectors: tool aliases incl. OpenClaw/Devin `exec`, `workdir`, glob directories, Windows paths, and a POSIX-host leading-`//` command via `/{root}/docs/adr/x.md`), `shell_tool_shapes_of_every_adapter_honor_exclusions`, `fixture_vectors` (incl. TS-adapter `bash`/`exec` vectors, and `normalization`'s leading-`//` POSIX vectors alongside the kept Windows-cwd UNC vectors as the no-regression control); `shell_matching_is_off_when_inactive_and_fails_closed_when_invalid_or_over_budget`; `router.rs` `capture_protocol_shell_decisions_survive_server_reinspection`, `capture_protocol_invalid_marker_shell_is_metadata_only` (active metadata-only shell refused, older client's invalid-marker keep stripped, commandless control kept), `capture_protocol_unparseable_marker_strips_shell_events` (server fallback for an unparseable marker), `capture_protocol_invalid_shell_metadata_claim_must_be_canonical` (a stripped shell claim with a path count or non-`extracted` state is refused); `capture_policy.rs` `invalid_marker_strips_shell_calls_with_unparseable_commands`, `long_bash_lc_script_in_argv_is_not_dropped_by_the_match_budget`, **`a_leading_double_slash_candidate_does_not_escape_posix_ignore_paths_via_flavor_mismatch`** (GHSA-vh98 adversarial: attempts the `//repo/secret/...` violation on a POSIX host, proves it now drops, with a plain-single-slash control and a Windows-hosted genuine-UNC control both still correct — fails on the pre-fix code); `hook.rs` `shell_command_reading_an_ignored_path_is_dropped_before_spool`; `render_shared.rs` `generated_capture_policy_v1_node_runtime_evidence` (runs the same fixture sections, including the GHSA-vh98 vectors, against the emitted TypeScript; `#[ignore]` locally, run with `--ignored` under Node 24 by the Linux CI test job) | STRONG |
|
||||
| 11c | Capture hook ≤200ms budget (invariant #5) | `hooks/_lib.sh` capture path `curl --max-time 0.2` (context-fetch 1.0s and background drain 2.0s are separate, larger-budget paths) | none (shell-script timeout; hard to unit-test) — watch on any capture-path change | WATCH |
|
||||
| 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) |
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -153,6 +153,17 @@ root_subject = "<root-subject>"
|
||||
exact match with `root_issuer` plus `root_subject`.
|
||||
- Origin health checks or maintenance calls that need root should use the root
|
||||
bearer, not the proxy bearer. Raw actor headers on the root rung are ignored.
|
||||
- **A proxied non-root end-user is not subject to per-project `restricted`
|
||||
grants.** The proxy branch authenticates the request as `AuthLevel::User`
|
||||
with an `ActorContext`, but — unlike a database user — never maps it to a
|
||||
database `UserId`/`AuthorizedViewer`: grants are keyed on `UserId`, and a
|
||||
proxied identity has none to key on. A missing `AuthorizedViewer` reads as
|
||||
"no per-project check applies" (the same as root, or an install with no
|
||||
database users at all), so a `restricted` project is readable by any
|
||||
proxied user with no grant. This is by design, not a gap: in a trusted-proxy
|
||||
deployment the proxy itself is the authorization boundary. If you need
|
||||
per-project `restricted` enforcement for individual end users, issue them
|
||||
database-user tokens (see above) instead of relying on the proxy headers.
|
||||
|
||||
## Identity keys
|
||||
|
||||
|
||||
Reference in New Issue
Block a user