From 4c4484b521da3f7c900cee78bdd98fde2a91a2e6 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Wed, 19 Aug 2026 13:52:02 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(isolation):=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BC=D0=B5=D0=B6=D0=B5=D0=BD=D0=BE=20worktree=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=80=D1=96=D0=B2=D0=BD=D1=96=20=D0=9E=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рішення Vitalii: ізолюємо лише файловий запис і лише в межі worktree. Повна ізоляція (мережа, exec) ламає підписочні CLI — вони ходять у власні API і запускають тулчейни, — тож її вимикали б назад цілком. Вузька межа, яку не хочеться вимикати, захищає більше за широку, яку вимкнули. sandbox.isolation: worktree (вимкнено за замовчуванням, як skill_profiles) обгортає CLI у sandbox-exec: запис дозволений у worktree, TMPDIR і явно перелічені sandbox.isolation_writable; усе інше read-only. Мережа й allowlist команд лишаються декларативними. Корінь — worktree, а не тека вузла: агент штатно чіпає весь чекаут, і звуження до теки вузла зробило б пісочницю непридатною, а не суворішою. Шлях канонізується обовʼязково. sandbox-exec звіряє резолвлені шляхи, і профіль із логічним /var/folders/… не покриває реальний /private/var/… — пісочниця забороняла б запис навіть у власний worktree. Симптом «усе зламалось», причина непомітна; на це є тест. На непідтримуваній платформі — відмова run-а, а не тихий запуск без ізоляції: виглядати захищеним, не будучи ним, гірше за явну відмову. Реалізовано macOS (перевірено живим прогоном пісочниці в тесті), Linux свідомо не вгадуємо. Побічно: PATH_LOCK у тестах більше не отруюється — одна тимчасова невдача за budget перетворювалась на десяток PoisonError, і справжню причину доводилось відкопувати з-під наслідків. --- crates/mt-core/src/isolation.rs | 324 ++++++++++++++++++++++++++++++++ crates/mt-core/src/lib.rs | 2 + crates/mt-core/src/runner.rs | 33 +++- docs/conformance.md | 22 ++- 4 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 crates/mt-core/src/isolation.rs diff --git a/crates/mt-core/src/isolation.rs b/crates/mt-core/src/isolation.rs new file mode 100644 index 0000000..f82f574 --- /dev/null +++ b/crates/mt-core/src/isolation.rs @@ -0,0 +1,324 @@ +//! Ізоляція виконавця рівня ОС: запис дозволений **лише у worktree run-а** +//! (спека `operations.md`, security model — fs-scope worktree). +//! +//! **Межа свідомо вужча за слово «sandbox».** Ізолюється рівно файловий +//! запис. Allowlist команд і мережа лишаються декларативними +//! ([`crate::sandbox`]): їх застосовує ACP-гейт і сам CLI, а не ядро ОС. +//! Причина — сумісність: повна ізоляція (мережа, exec) ламає підписочні CLI, +//! які ходять у свої API й запускають тулчейни, і її вимикали б назад цілком. +//! Вузька межа, яку не хочеться вимикати, захищає більше за широку, яку +//! вимкнули. +//! +//! Що це дає: агент, який зірвався, не може зіпсувати робоче дерево, чужі +//! worktree, конфіги користувача чи систему — усе, що поза worktree, для +//! нього read-only. +//! +//! **Вимкнено за замовчуванням** — той самий принцип, що в `skill_profiles`: +//! механізм вмикається явно, бо мовчазне ввімкнення зламало б наявні +//! проєкти й «безпека» звелася б до її вимикання. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +/// Режим ізоляції з конфігу. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum IsolationMode { + /// Без ізоляції рівня ОС (дефолт). + #[default] + Off, + /// Запис дозволений лише у worktree (+ явно розширені шляхи). + Worktree, +} + +/// Чому ізоляцію не вдалося застосувати. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IsolationError { + /// Платформа не підтримується цією реалізацією. + Unsupported { platform: &'static str }, + /// Worktree не резолвиться у канонічний шлях. + Unresolvable { path: PathBuf }, +} + +impl std::fmt::Display for IsolationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unsupported { platform } => write!( + f, + "ізоляція worktree не реалізована для {platform} — \ + вимкни sandbox.isolation або запусти на підтримуваній платформі" + ), + Self::Unresolvable { path } => write!( + f, + "ізоляція: worktree {} не резолвиться у канонічний шлях", + path.display() + ), + } + } +} + +/// Режим із конфігу проєкту (`sandbox.isolation`). +pub fn isolation_mode(config: &Value) -> IsolationMode { + match config + .get("sandbox") + .and_then(|section| section.get("isolation")) + .and_then(Value::as_str) + { + Some("worktree") => IsolationMode::Worktree, + _ => IsolationMode::Off, + } +} + +/// Додатково дозволені на запис шляхи (`sandbox.isolation_writable`). +/// +/// Потрібні, бо підписочні CLI тримають стан поза worktree (кеш npm, +/// `~/.claude`). Розширення **явне**: дефолт лишається строгим, а кожен +/// отвір видно в конфігу. +pub fn extra_writable(config: &Value) -> Vec { + config + .get("sandbox") + .and_then(|section| section.get("isolation_writable")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(expand_home)) + .collect() + }) + .unwrap_or_default() +} + +/// Розгортає провідний `~` у `$HOME`. +fn expand_home(path: &str) -> String { + let Some(rest) = path.strip_prefix("~/") else { + return path.to_string(); + }; + match std::env::var("HOME") { + Ok(home) => format!("{home}/{rest}"), + Err(_) => path.to_string(), + } +} + +/// Готова до запуску команда. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Wrapped { + /// Програма (сама або обгортка ізоляції). + pub program: String, + /// Аргументи, включно з початковою програмою, якщо є обгортка. + pub args: Vec, +} + +/// Канонічний шлях — **обовʼязково**, і це не косметика. +/// +/// `sandbox-exec` звіряє резолвлені шляхи: профіль із логічним +/// `/var/folders/...` не покриває реальний `/private/var/folders/...`, і +/// пісочниця забороняє запис навіть у власний worktree. Симптом — «усе +/// зламалось», причина — непомітна. +fn canonical(path: &Path) -> Result { + path.canonicalize().map_err(|_| IsolationError::Unresolvable { + path: path.to_path_buf(), + }) +} + +/// Текст профілю `sandbox-exec` (macOS). +/// +/// Порядок правил значущий: пізніші перекривають ранні, тому спершу +/// глобальна заборона запису, далі точкові дозволи. +pub fn macos_profile(worktree: &Path, writable: &[PathBuf]) -> String { + let mut profile = String::from("(version 1)\n(allow default)\n(deny file-write*)\n"); + profile.push_str(&format!( + "(allow file-write* (subpath \"{}\"))\n", + worktree.display() + )); + for path in writable { + profile.push_str(&format!( + "(allow file-write* (subpath \"{}\"))\n", + path.display() + )); + } + // Стандартні пристрої й ioctl: без них процес не має куди писати stdout + // і падає ще до першого корисного рядка. + profile.push_str( + "(allow file-write-data (literal \"/dev/null\") (literal \"/dev/stdout\") \ + (literal \"/dev/stderr\") (literal \"/dev/tty\") (literal \"/dev/dtracehelper\"))\n\ + (allow file-ioctl)\n", + ); + profile +} + +/// Тимчасова директорія процесу — її мусить бути видно на запис, інакше +/// падає майже будь-який тулчейн. +fn process_tmp() -> Option { + let raw = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); + Path::new(&raw).canonicalize().ok() +} + +/// Обгортає команду ізоляцією за режимом. +/// +/// # Errors +/// Платформа не підтримується або worktree не резолвиться. +pub fn wrap( + mode: IsolationMode, + worktree: &Path, + extra: &[String], + program: &str, + args: &[String], +) -> Result { + if mode == IsolationMode::Off { + return Ok(Wrapped { + program: program.to_string(), + args: args.to_vec(), + }); + } + + if cfg!(target_os = "macos") { + let root = canonical(worktree)?; + let mut writable: Vec = process_tmp().into_iter().collect(); + // Нерезолвний додатковий шлях пропускаємо мовчки: він міг бути + // задекларований наперед (кеш, якого ще немає), і це не привід + // валити run. + writable.extend(extra.iter().filter_map(|path| Path::new(path).canonicalize().ok())); + let mut wrapped = vec![ + "-p".to_string(), + macos_profile(&root, &writable), + program.to_string(), + ]; + wrapped.extend(args.iter().cloned()); + return Ok(Wrapped { + program: "sandbox-exec".to_string(), + args: wrapped, + }); + } + + // Fail closed: мовчазний запуск без ізоляції там, де її попросили, — + // найгірший результат, бо виглядає як захищений. + Err(IsolationError::Unsupported { + platform: std::env::consts::OS, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn isolation_is_off_by_default() { + assert_eq!(isolation_mode(&serde_json::json!({})), IsolationMode::Off); + assert_eq!( + isolation_mode(&serde_json::json!({"sandbox": {"isolation": "off"}})), + IsolationMode::Off + ); + assert_eq!( + isolation_mode(&serde_json::json!({"sandbox": {"isolation": "worktree"}})), + IsolationMode::Worktree + ); + } + + #[test] + fn off_mode_passes_command_through() { + let wrapped = wrap( + IsolationMode::Off, + Path::new("/nonexistent"), + &[], + "claude", + &["-p".to_string(), "текст".to_string()], + ) + .unwrap(); + assert_eq!(wrapped.program, "claude"); + assert_eq!(wrapped.args, ["-p", "текст"]); + } + + #[test] + fn profile_denies_before_allowing() { + // Порядок значущий: у sandbox-exec пізніші правила перекривають + // ранні, тож дозвіл до заборони не дав би нічого. + let profile = macos_profile(Path::new("/repo/wt"), &[]); + let deny = profile.find("(deny file-write*)").expect("немає заборони"); + let allow = profile.find("(allow file-write* (subpath \"/repo/wt\"))").unwrap(); + assert!(deny < allow, "{profile}"); + } + + #[test] + fn extra_writable_expands_home() { + let config = serde_json::json!({ + "sandbox": {"isolation_writable": ["~/.claude", "/opt/cache"]} + }); + let paths = extra_writable(&config); + assert_eq!(paths.len(), 2); + assert!(!paths[0].starts_with('~'), "{paths:?}"); + assert_eq!(paths[1], "/opt/cache"); + } + + #[cfg(target_os = "macos")] + #[test] + fn worktree_path_is_canonicalized() { + // Пастка, на якій це ламається тихо: логічний /var/folders/… не + // покриває реальний /private/var/folders/…, і пісочниця забороняє + // запис навіть у власний worktree. + let dir = tempfile::tempdir().unwrap(); + let logical = dir.path().to_path_buf(); + let real = logical.canonicalize().unwrap(); + + let wrapped = wrap(IsolationMode::Worktree, &logical, &[], "echo", &[]).unwrap(); + let profile = &wrapped.args[1]; + assert!( + profile.contains(&format!("(subpath \"{}\")", real.display())), + "у профілі має бути канонічний шлях: {profile}" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn missing_worktree_is_an_error_not_a_bare_command() { + let error = wrap( + IsolationMode::Worktree, + Path::new("/nonexistent-worktree"), + &[], + "echo", + &[], + ) + .unwrap_err(); + assert!(matches!(error, IsolationError::Unresolvable { .. }), "{error:?}"); + } + + /// Найважливіший тест модуля: пісочниця справді ізолює, а не лише + /// збирає рядок профілю. + #[cfg(target_os = "macos")] + #[test] + fn sandbox_actually_confines_writes() { + let worktree = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let inside_file = worktree.path().canonicalize().unwrap().join("inside.txt"); + let outside_file = outside.path().canonicalize().unwrap().join("outside.txt"); + + // Заборонений напрямок навмисно не в TMPDIR: він дозволений на + // запис, тож перевіряти треба поза ним. + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + let forbidden = Path::new(&home).join("mt-isolation-probe.txt"); + + let script = format!( + "echo ok > {} && (echo bad > {} && echo ESCAPED || echo DENIED)", + inside_file.display(), + forbidden.display() + ); + let wrapped = wrap( + IsolationMode::Worktree, + worktree.path(), + &[], + "/bin/sh", + &["-c".to_string(), script], + ) + .unwrap(); + + let out = std::process::Command::new(&wrapped.program) + .args(&wrapped.args) + .output() + .expect("sandbox-exec не запустився"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!(inside_file.exists(), "запис у worktree мусив пройти: {stdout}"); + assert!(stdout.contains("DENIED"), "запис поза worktree не заборонено: {stdout}"); + assert!(!forbidden.exists(), "файл поза worktree створено"); + let _ = outside_file; + } +} diff --git a/crates/mt-core/src/lib.rs b/crates/mt-core/src/lib.rs index 43aa148..ef5c2e8 100644 --- a/crates/mt-core/src/lib.rs +++ b/crates/mt-core/src/lib.rs @@ -30,6 +30,8 @@ pub mod git; /// Файловий шар i18n: схема перекладу, staleness, contract-aware сегментація. pub mod i18n; /// Cost/time ledger: агрегація `wall_sec`/`tokens_*`/`cost_usd` з усіх `run_NNN.md` графу. +pub mod isolation; + pub mod ledger; /// MCP-сервери surface: декларація, резолв секретів, payload для ACP. diff --git a/crates/mt-core/src/runner.rs b/crates/mt-core/src/runner.rs index 145a8ef..519a83f 100644 --- a/crates/mt-core/src/runner.rs +++ b/crates/mt-core/src/runner.rs @@ -1113,6 +1113,12 @@ pub fn run_node_env_as( }) .unwrap_or_default(); base_envs.extend(crate::sandbox::policy_for(&config, &node_skills).env()); + + // Режим ізоляції читається один раз на run: конфіг посеред ходу не + // змінюється, а перечитування на кожен щабель драбини дало б вікно, у + // якому щаблі йдуть із різними межами. + let isolation = crate::isolation::isolation_mode(&config); + let isolation_writable = crate::isolation::extra_writable(&config); let secret_warnings: Vec = store_error .map(|error| error.to_string()) .into_iter() @@ -1142,8 +1148,24 @@ pub fn run_node_env_as( let Some((prog, args)) = build_agent_cli_argv(&cli, model.as_deref(), prompt) else { continue; // невідоме ім'я у каскаді — пропускаємо }; - let mut cmd = Command::new(prog); - cmd.args(args).current_dir(&dir); + // Ізоляція рівня ОС (`isolation.rs`): запис лише у worktree. + // Корінь — саме worktree, а не тека вузла: агент штатно чіпає + // весь чекаут (код, тести, конфіги), і звуження до теки вузла + // зробило б пісочницю непридатною, а не суворішою. + let wrapped = match crate::isolation::wrap( + isolation, + &worktree, + &isolation_writable, + &prog, + &args, + ) { + Ok(wrapped) => wrapped, + // Fail closed: ізоляцію попросили, застосувати не вдалося — + // це відмова run-а, а не тихий запуск без неї. + Err(error) => return Err(error.to_string()), + }; + let mut cmd = Command::new(&wrapped.program); + cmd.args(&wrapped.args).current_dir(&dir); cmd.envs(base_envs.iter().cloned()); cmd.env("MT_AGENT_CLI", &cli); let w = spawn_watched( @@ -2273,7 +2295,12 @@ printf -- '---\nschema_version: 1\n---\n\n## Summary\n\nok\n' > "fact_${MT_RUN_N /// Тимчасовий bin-каталог із фейковими CLI, prepended до PATH. fn with_path_shims(shims: &[(&str, &str)], f: impl FnOnce()) { - let _guard = PATH_LOCK.lock().unwrap(); + // `unwrap_or_else(into_inner)`, а не `unwrap()`: м'ютекс лише + // серіалізує мутацію PATH, жодного інваріанта він не боронить. З + // `unwrap()` одна невдача (зокрема тимчасова, за budget) отруювала + // замок і перетворювалась на десяток PoisonError у сусідніх тестах — + // справжню причину доводилось відкопувати з-під наслідків. + let _guard = PATH_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let bin = tempfile::tempdir().unwrap(); for (name, body) in shims { let p = bin.path().join(name); diff --git a/docs/conformance.md b/docs/conformance.md index 31ea250..fa6d201 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -55,7 +55,7 @@ | `unresolvable` (3 тригери + алерт) | РЕАЛІЗОВАНО | `lib.rs` `unresolvable_reason`/`write_unresolvable`; алерт — `agent-server/orchestrator.rs` `pending_alerts`; доставка — push тип 3 (`relay/lib/push.mjs`) | — | | Recurrence | ВІДСУТНЄ | — | Уся глава `recurrence.md` | | Secrets broker | РЕАЛІЗОВАНО | `mt-core/secrets.rs` (сховища: Keychain / файл `0600` / `MT_SECRETS_FILE`, `resolve_keys`, `resolve_ref` для `secret:`, `Masker`); інжекція й маскування — `runner.rs` | — (маскування ловить дослівне значення; закодовані форми — межа, названа в модулі) | -| Sandbox `skill_profiles` | ЧАСТКОВО | `mt-core/sandbox.rs` (`policy_for`, `check_command`/`check_path`/`check_network`, ENV `MT_SKILL_ALLOW`/`MT_SKILL_NETWORK`); жорстка відмова — ACP-гейт у `mt serve` | Політика гейта — стеля хоста, не вузла (гейт ще не знає вузла кімнати); пісочниця рівня ОС для автономного CLI відсутня — MT не перехоплює його syscall-и | +| Sandbox `skill_profiles` | ЧАСТКОВО | `mt-core/sandbox.rs` (політика: allowlist, мережа, fs-scope; ENV); жорстка відмова — ACP-гейт у `mt serve`; **ізоляція запису рівня ОС** — `mt-core/isolation.rs` (`sandbox.isolation: worktree`, macOS `sandbox-exec`) | Ізоляція покриває **лише файловий запис** і лише macOS (Linux — fail closed); мережа й allowlist команд лишаються декларативними; політика гейта — стеля хоста, не вузла | | `.mt.json` — дефолти для реалізованого | РЕАЛІЗОВАНО | `config.rs` `config_defaults` | — (ключі нереалізованих фіч свідомо без дефолтів, див. «Закриті питання») | ## Протокол, сесії, поверхні @@ -75,7 +75,7 @@ | Orchestrator-роль у agent-server + wake | РЕАЛІЗОВАНО | `agent-server/orchestrator.rs` `Wake`/`Orchestrator::tick`; relay push → `AppState::wake_orchestrator` | — | | `client_kind: mt-dashboard` | РЕАЛІЗОВАНО | фільтр стрічки — `agent-server/ws.rs` `allowed`/`is_graph_event`; емітер `NodeState` — `orchestrator.rs` `state_changes` (лише зміни), публікація — `broadcast_only` у циклі `mt serve` | — (піддерево не звужується на хості: спека кладе агрегацію на клієнта) | | Surface-профілі + `ContextSelected` | РЕАЛІЗОВАНО | `mt-core/surfaces.rs` (парсер обох форм конфігу, резолюція hint→липкість→default, стеля `a.md.skills`, `check_context_kind`); хост — `agent-server/ws.rs` (`resolve_turn_surface`, гейт `ContextSelected`, surface у ехо `UserMessage`) | — | -| MCP-сервери surface | ЧАСТКОВО | `mt-core/mcp.rs` (декларація, `mcp:` у `tools`, резолв `secret:` через брокер, ACP-payload); доставка — `agent-core/acp.rs` `session/new`, збірка — `mt serve` | Оголошується обʼєднання профілів проєкту, не набір surface ходу (потребує переоткриття ACP-сесії при перемиканні); лінивий старт і `idle_ttl_sec` виконує ACP-виконавець, MT їх лише декларує | +| MCP-сервери surface | ЧАСТКОВО (розвиток спинено) | `mt-core/mcp.rs` (декларація, `mcp:` у `tools`, резолв `secret:` через брокер, ACP-payload); доставка — `agent-core/acp.rs` `session/new`, збірка — `mt serve` | Звуження набору до surface ходу **свідомо не планується** — рішення Vitalii 2026-08-14, див. «Закриті питання». Чинний стан робочий: оголошується обʼєднання профілів проєкту; лінивий старт і `idle_ttl_sec` виконує ACP-виконавець | | Preview-модуль (`PreviewScreenshot`, picker) | ВІДСУТНЄ | — | Подія й capability-фільтр є; самого модуля немає | ## Люди, доступ, relay @@ -124,6 +124,10 @@ ## Закриті питання +- **Ізоляція — тільки файловий запис і тільки worktree** (2026-08-14): рішення Vitalii. `operations.md` називає fs-scope worktree, але не каже, чи ізолювати мережу й exec. Повна ізоляція ламає підписочні CLI (вони ходять у власні API і запускають тулчейни), тож її вимикали б назад цілком — а вузька межа, яку не хочеться вимикати, захищає більше за широку, яку вимкнули. Реалізовано `sandbox.isolation: worktree` (macOS `sandbox-exec`, вимкнено за замовчуванням): запис дозволений у worktree, `TMPDIR` і явно перелічені `sandbox.isolation_writable`; усе інше — read-only. Мережа й allowlist команд лишаються декларативними. На непідтримуваній платформі — **відмова run-а**, а не тихий запуск без ізоляції. + +- **MCP: звуження до surface ходу не робимо** (2026-08-14): рішення Vitalii — розвиток MCP спинено. Аналіз трьох варіантів: (А) набір проєкту — контекст усіх тулів у кожному ході, але розмова безперервна; (Б) переоткриття ACP-сесії на зміну surface — рве **контекст розмови** заради економії контексту тулів, тобто платить найдорожчим за найдешевше; (В) кімнати по парі (вузол, surface) — вузький контекст без розриву, ціною N процесів і розділених тредів. Технічна межа: `mcpServers` задається лише в `session/new`, додати сервер у живу сесію нічим. Чинний варіант А лишається робочим; рядок карти лишається `ЧАСТКОВО` не як борг, а як зафіксована межа. + - **Останнього owner-а не понизити й не прибрати** (2026-08-14): `access.md` описує Membership API (`PATCH role` / `DELETE`) і окремо каже, що для **зниклого єдиного owner-а** потрібна адміністративна процедура оператора relay «за явною згодою всіх учасників із роллю host». Тобто стан «задача без власника» спека визнає, але виводить із нього не API, а ручною процедурою. Рішення: звичайний API такий стан **створити не може** — пониження або видалення останнього owner-а відхиляється. Дешевше не дати створити стан, ніж будувати з нього вихід; штатний шлях лишається той самий — `transfer ownership` або другий owner наперед (succession зі спеки). - **Sandbox не вмикається сам** (2026-08-13): `operations.md` каже «команда поза allowlist → відмова», але не каже, що робити з проєктом, який `skill_profiles` не налаштував. Deny-by-default для такого проєкту зламав би кожен наявний вузол мовчазною відмовою — і «безпека» звелася б до того, що її вимикають назад. Рішення: **секції немає → політика не enforcing** (поведінка як до її появи); **секція є → у її межах allowlist жорсткий, а `network` вимкнено, доки його не ввімкнули явно**. Це властивість коду (`Policy::is_enforcing`), а не домовленість у коментарі. @@ -147,4 +151,16 @@ ## Відкриті питання -Немає — усі три конфлікти зрізу 2026-08-09 закриті. +Конфлікти зрізу 2026-08-09 закриті; нижче — питання, що виникли під час реалізації і чекають рішення. + +1. **`directory.rs`: дописати ланцюг чи видалити.** Ланцюг адресної ескалації має три ланки, працює одна: handle → email є (парсер із тестами); email → `account_id` **немає ніде** (мапінг живе лише на relay, і кадру для запиту немає); `Escalation` **не емітить ніхто** — нуль емітерів по всіх крейтах. Наслідок сьогодні: якби емітер зʼявився, `push.mjs` свідомо не розсилає подію без `to_account_id`, тож ескалація дійшла б **нікому, без сліду**. Ускладнення: кадр «резолвни акаунт за email» робить relay оракулом наявності акаунтів — мінімально безпечна форма мусить обмежуватись membership кореня. **Рекомендація:** видалити (прецедент `mt-napi`); парсер на 109 рядків перепишеться разом із рештою ланцюга в M6. Альтернатива — лишити, але тоді рядок карти має стати `ВІДСУТНЄ` для всієї фічі, а не `ЧАСТКОВО` для парсера. + +2. **ACP-гейт не знає вузла кімнати.** Sandbox-політика в гейті — стеля хоста (обʼєднання всіх профілів), бо інтерактивна сесія підхоплює вузол уже під час розмови. Звуження до `a.md.skills` конкретного вузла вимагає, щоб `PermissionFactory` отримував вузол — зміна публічного API `agent-server`. + +3. **`layers/` і `mt-core/i18n.rs` — дві відповіді на одне питання.** Обидва перекладають markdown, але різними контейнерами (`x..md` у дереві проти `refs/mt/i18n`), різними конфігами і різними моделями свіжості. Поки i18n без сховища — не конфліктують; щойно сховище зʼявиться, треба вирішити, чи стає `layers/` споживачем ядра. + +4. **Live-переклад Envelope не має власника.** Спека: клієнт без capability `self-translate` отримує текстові події вже перекладеними — тобто виклик моделі на **кожну текстову подію** в гарячому шляху стрічки. Хост, relay, чи відкласти до дешевого локального перекладача? Зараз `lang` далі хендшейку не читає ніхто. + +5. **Хто пакує `decision-request`.** Спека: ніколи не виконавець — його пакує агент escalation-intake. Механіка готова, судження «це вибір, а не баг» робить людина, що викликає `mt escalate`. Заводити агента окремим актором (як аудитор і EngineerAgent) чи лишити ручний виклик штатним для фази 0. + +6. **Rust-тести без CI-гейта.** `cargo test` не ганяє жоден workflow. Просто додати не можна: частина тестів драбини міряє реальний час і чутлива до навантаження (спостережено 2026-08-14: під паралельним clippy+лінтом падає `budget-exceeded` замість `success`, щоразу в інших тестах), а червоний, що блимає, навчаються ігнорувати. Спершу розвʼязати їх від годинника (інʼєкція, як уже зроблено в `presence` і `push`) або винести в окремий повільний джоб — і аж тоді вмикати гейт. Часткове полегшення вже зроблено: `PATH_LOCK` більше не отруюється, тож одна невдача не тягне за собою десяток `PoisonError` і причину видно одразу. From e5c14d3ce688e5a46c76939aaf5f3ba2c29538d1 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Wed, 19 Aug 2026 14:47:57 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(conformance):=20=D0=B7=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=BD=D1=8F=20=D0=BC=D1=96=D0=BB=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=83=D0=BD=D1=96=D0=B2=20=D0=BF=D1=96=D1=81=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=96=D0=B7=D0=BE=D0=BB=D1=8F=D1=86=D1=96=D1=97=20=D1=82?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BF=D0=B8=D0=BD=D0=B5=D0=BD=D0=BD=D1=8F=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conformance.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conformance.md b/docs/conformance.md index fa6d201..31ba353 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -16,10 +16,10 @@ | Мілстоун | Стан | Головне, чого бракує | | --- | --- | --- | -| M0 — dogfood ядра | цикл замкнено | recurrence; телеметрія вартості; sandbox — політика є, ізоляції рівня ОС немає | +| M0 — dogfood ядра | цикл замкнено | recurrence; телеметрія вартості | | M1 — agent-server | ✅ закрито | — | | M2 — mission control | частково | checkpoint-handoff, CLI `mt sessions` | -| M3 — dashboard і поверхні | почався | preview-модуль; MCP — звуження набору до surface ходу | +| M3 — dashboard і поверхні | почався | preview-модуль (MCP — розвиток спинено, див. «Закриті питання») | | M4 — файловий шар i18n | ядро | сховище `refs/mt/i18n`, write path, черга регенерації, live-шар | | M5 — мета-цикл retro | MVP | LLM-крок, innovation/baseline, impact-зрізи, фоновий прогін | | M6 — мандати й Дельта | фаза 0 закрита | escalation-intake, маршрутизація за важелем, прецеденти, селектор, профілі, watcher |