//! Folder-trust gate ("do you trust this folder?"). //! //! Repo-local MCP / LSP servers are configured by files an attacker can ship //! inside a cloned repository (`.mcp.json`, project `.kigi/config.toml`, //! `~/.claude.json` `projects.`, project `.kigi/lsp.json`). Those configs //! contain commands that the CLI would otherwise spawn automatically — a //! 1-click RCE. This module resolves a VS-Code-style trust decision ONCE per //! workspace, BEFORE any repo-local server is spawned, and exposes a cheap //! [`project_scope_allowed`] check that the MCP/LSP loaders consult. //! //! Resolution lives here (not in `acp_session`) so the session core stays free //! of feature logic; the loaders only call [`project_scope_allowed`]. //! //! The DECISION side — the workspace scan, the pure [`decide`] precedence, the //! interactive prompt, and the durable [`kigi_workspace::trust::TrustStore`] //! reads/writes — lives in `kigi-workspace` (client-side); this module keeps //! the CONSUME/gating side (the `DECISIONS` cache, [`resolve_and_record`], and //! the loader filters). The ordered trust precedence is documented canonically //! on [`kigi_workspace::folder_trust::decide`]; the consume-side nuance is //! that two allows are PROVISIONAL (NOT cached): the "no repo configs" allow — so //! configs appearing after the first resolve (git pull / agent write) are //! re-checked on the next resolve rather than riding a stale grant — and the //! unrecordable-key allow (cwd is $HOME / fs-root), which can never be persisted //! anyway (see [`resolve_and_record_inner`] / [`compute`]). use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use agent_client_protocol as acp; use kigi_workspace::trust::{TrustStore, is_unsafe_trust_root, workspace_key}; use parking_lot::Mutex; // The decision-side (scan/decide/prompt/store) lives in `kigi-workspace` // (client crate). `grant_folder_trust` is the ONLY item referenced from // OUTSIDE this module (shell call sites + the pager's // `kigi_shell::agent::folder_trust::grant_folder_trust`), so only it is // re-published; the rest are private imports used within this module. A glob // re-export is deliberately avoided: it would silently re-publish the // cache-SKIPPING `revoke_folder_trust_store` next to the real // `revoke_folder_trust` wrapper, inviting a stale-untrust security bug. pub use kigi_workspace::folder_trust::grant_folder_trust; use kigi_workspace::folder_trust::{ DecideInputs, TrustOutcome, claude_project_mcp_names, decide, decide_inputs, decide_inputs_with_interactive, feature_enabled, folder_trust_inert, persist_trust, prompt_for_trust, }; use crate::session::managed_mcp::mcp_server_name; use crate::util::config::{MCP_SCOPE_PROJECT, RemoteSettings}; // NOTE: this folder-trust store (`~/.kigi/trusted_folders.toml`) is SEPARATE // from the per-plugin trust store // (`kigi_agent::plugins::TrustStore` at `~/.kigi/trusted-plugins`, plus the // hooks' own project-trust gating). Trusting a folder here does NOT imply plugin // trust and vice versa; the two are independent and non-contradicting. // Unifying them is a tracked follow-up. /// Per-workspace resolved decision: `true` = repo-local (project-scoped) /// servers are allowed to spawn. Keyed by canonical workspace key. static DECISIONS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); /// Revoke trust for `cwd`'s workspace: downgrade the in-process decision cache /// so a mid-session untrust takes effect immediately, while the store half — /// persisting an explicit `set_untrusted` ONLY when the folder was actually /// trusted — is delegated to /// [`kigi_workspace::folder_trust::revoke_folder_trust_store`]. /// /// Without the cache downgrade a cached grant would short-circuit /// [`resolve_and_record`] (which only reconciles untrusted→trusted), so hooks /// would keep loading until restart. Unrecordable roots ($HOME / fs-root) are /// refused instead: [`decide`] always trusts them and the store refuses both /// their grants and denies, so a cache deny would be the one verdict nothing /// (grant, store, prompt) could ever lift. Returns whether the folder had been /// trusted. Symmetric with [`grant_folder_trust`]. pub fn revoke_folder_trust(cwd: &Path) -> bool { // Local/dev builds are fully inert: nothing was trusted-via-gate to revoke, // and recording `false` here would make `project_scope_allowed` wrongly gate. if folder_trust_inert() { return false; } let key = workspace_key(cwd); // Mirror the store's over-broad-root refusal: an unrecordable key resolves // Trusted by rule and can never be store-granted, so a cache deny here would // be permanent for the process with no in-product recovery. if is_unsafe_trust_root(&key) { tracing::warn!( key = %key.display(), "refusing folder-trust revoke for an over-broad root (never gated)" ); return false; } let was_trusted = kigi_workspace::folder_trust::revoke_folder_trust_store(cwd); // Always downgrade the in-process cache so a mid-session untrust takes effect // immediately for this process, even for a cached grant with no backing store // record (e.g. a kill-switch / feature-off resolve). A later legitimate grant // reconciles it: the `Some(false)` arm of `resolve_and_record_inner` re-checks // the store. record(&key, false); was_trusted } /// Whether repo-local (project-scoped) MCP/LSP servers may spawn for `cwd`. /// /// Authoritative and fail-closed, mirroring [`resolve_and_record_inner`]'s arms: /// a cached **grant** short-circuits (allow); a cached **untrusted** verdict is /// RE-READ against the store so a `grant_folder_trust` issued AFTER the untrusted /// resolve is honored (it records the upgrade and allows); an **unrecorded** key /// re-resolves via [`resolve_and_record`] — deny ONLY the dangerous case (feature /// on AND repo-local code-exec configs present AND untrusted); allow no-configs / /// unrecordable-key ($HOME / fs-root) / store-trusted / feature-off / inert. So /// this never over-denies the common no-configs case, whose Trusted verdict is /// provisional and therefore never cached. /// /// The cache is consulted BEFORE delegating so a recorded `Some(false)` is /// reconciled even on an inert build, where [`resolve_and_record`] would short- /// circuit to allow before reaching the cache. `remote = None`: durable /// feature-off / kill-switch verdicts are already cached by the launch/session /// resolve that ran with the real RemoteSettings. /// /// `DECISIONS` uses `parking_lot::Mutex` (no poisoning), so this gate cannot /// fail OPEN on a poisoned lock. pub fn project_scope_allowed(cwd: &Path) -> bool { let key = workspace_key(cwd); // Copy out of the lock so the Some(false) reconcile can re-acquire it // (parking_lot mutexes are not re-entrant). let cached = DECISIONS.lock().get(&key).copied(); match cached { Some(true) => true, // Re-read the store so a grant issued after the untrusted resolve is // honored without a restart (mirrors `resolve_and_record_inner`). Some(false) => { if TrustStore::load().is_trusted(&key) { record(&key, true); true } else { false } } // Unrecorded: re-resolve fail-closed (no-configs / trusted / feature-off / // inert allow; untrusted + configs deny). None => resolve_and_record(cwd, None, false), } } /// Whether an interactive GUI trust PROMPT is warranted for `cwd`: the feature /// is on, the workspace is NOT store-trusted, and repo-local code-exec configs /// are present (something to gate). Interactivity is forced `true` because the /// caller already confirmed the client can prompt (it advertised /// `kigi/folderTrust.interactive`); the TTY-based [`decide_inputs`] default is /// false under the ACP stdio transport. Mirrors the [`decide`] precedence so it /// cannot drift from the gate: feature-off (kill-switch / opt-out) / store-trusted /// / no-configs all collapse to a non-`Prompt` verdict and return false. pub(crate) fn prompt_warranted(cwd: &Path, remote: Option<&RemoteSettings>) -> bool { let key = workspace_key(cwd); matches!( decide( feature_enabled(remote), &decide_inputs_with_interactive(cwd, &key, true), ), TrustOutcome::Prompt ) } /// Display-only summary of which repo-local code-exec config kinds are present for /// `cwd` — the reasons the folder is gated — for the interactive trust prompt's /// UI. Single-sourced from the SAME scan as the canonical gate /// ([`kigi_workspace::folder_trust::repo_config_kinds`] / /// [`repo_configs_present`]) so the prompt's reason list cannot drift from what /// actually gated the folder (same markers, same cwd→git-root walk). /// /// ALL detected kinds are reported, including `lsp`: it is a genuine reason the /// folder is gated (so an `.kigi/lsp.json`-only repo still has a non-empty reason /// list). Only the post-grant *hot-reload* skips LSP — project LSP applies on the /// next session open (the backend is spawn-baked into the tool bridge). See the /// `mvp_agent::folder_trust_prompt` module docs. pub(crate) fn detected_config_kinds(cwd: &Path) -> Vec { kigi_workspace::folder_trust::repo_config_kinds(cwd) .into_iter() .map(str::to_string) .collect() } /// Whether an agent's inline `hooks:` block may be appended to the live hook /// registry. A PROJECT/cwd-discovered agent's inline hooks are repo-controlled /// code-exec (and a project agent can SHADOW a built-in subagent, e.g. `explore`), /// so they require folder trust; user/bundled/built-in agents (not cwd-sourced) /// always keep theirs. `trusted` is evaluated LAZILY so non-project agents skip /// the (filesystem-walking) trust verdict entirely. SINGLE definition shared by /// the primary-session and subagent append sites (and the test) so they cannot /// drift. The primary site passes its already-computed `hooks_trusted` verdict; /// the subagent site passes `project_scope_allowed(parent_cwd)`. pub(crate) fn agent_inline_hooks_allowed( scope: kigi_agent::config::AgentScope, trusted: impl FnOnce() -> bool, ) -> bool { scope != kigi_agent::config::AgentScope::Project || trusted() } fn record(workspace_key: &Path, allowed: bool) { DECISIONS .lock() .insert(workspace_key.to_path_buf(), allowed); } /// Test-only: force the recorded decision for `cwd`'s workspace key. /// /// Tests use UNIQUE temp-dir keys and never globally clear `DECISIONS`, so they /// can run in parallel without clobbering each other's recorded decisions. /// /// Consumed by the MCP project-scope gate tests here and in `managed_mcp`. #[cfg(test)] pub(crate) fn record_for_test(cwd: &Path, allowed: bool) { record(&workspace_key(cwd), allowed); } /// Resolve the trust decision for `cwd` ONCE and record it for the loaders. /// /// Returns whether project-scoped servers are allowed. A cached **grant** /// short-circuits; a cached **untrusted** verdict is re-checked against the /// store so a later `--trust` grant is honored without a restart (see /// [`resolve_and_record_inner`]). Persists on an accepted interactive prompt; an /// explicit `--trust` grant is persisted up front by [`grant_folder_trust`]. /// /// `allow_prompt` must be `true` ONLY where a blocking stdin y/N read is safe — /// i.e. agent `initialize` for the launch directory, before the TUI takes over /// the terminal. Every other call site (per-session cwd, leader-served sessions /// whose cwd differs from the launch dir, `kigi mcp doctor`) passes `false`, so /// an unresolved interactive-but-untrusted workspace resolves **fail-closed** /// (untrusted, no prompt) — only the launch dir is ever prompted for. pub fn resolve_and_record(cwd: &Path, remote: Option<&RemoteSettings>, allow_prompt: bool) -> bool { // Local/dev builds are fully inert: project scope is always allowed, so skip // the `trusted_folders.toml` read entirely. if folder_trust_inert() { return true; } let key = workspace_key(cwd); resolve_and_record_inner( &key, || TrustStore::load().is_trusted(&key), || compute(cwd, &key, remote, allow_prompt), ) } /// Resolve the launch dir's project-scope trust verdict with a SINGLE expensive /// gather, recorded into `DECISIONS` exactly as [`resolve_and_record`] would, so /// the one-time deferred init helpers can share it. This is the AUTHORITATIVE /// description of the launch-dir dedup + TOCTOU contract (the `MvpAgent` /// field/method docs point here). /// /// The deferred init helpers (`ensure_plugin_registry` and /// `ensure_local_workspace_ops`) each gather independently, and the provisional /// "no repo configs" allow is non-durable (never absorbed by the `DECISIONS` /// cache), so without memoization the launch dir is scanned multiple times /// during init. This gathers [`decide_inputs`] (store read + `repo_configs_present` /// scan) ONCE and derives the verdict through the same [`resolve_and_record_inner`] /// cache contract, so durable verdicts are recorded into `DECISIONS` and the /// provisional allows (no-configs, unrecordable key) are left uncached. /// /// TOCTOU: this records ONLY what [`resolve_and_record`] records, so a later /// per-session `resolve_and_record(session_cwd)` still re-scans the provisional /// no-configs case (config added post-startup via git pull / agent write is /// caught). The init-time dedup belongs to the one-shot caller (a `OnceCell` on /// `MvpAgent`), NOT to any new shared-cache entry. pub fn resolve_launch_dir_trust(cwd: &Path, remote: Option<&RemoteSettings>) -> bool { // Local/dev builds are fully inert: project scope is always allowed, skipping // the store read + repo scan entirely. if folder_trust_inert() { return true; } let key = workspace_key(cwd); let feature = feature_enabled(remote); let inputs = decide_inputs(cwd, &key); // Re-read the store for the cached-untrusted reconciliation EXACTLY as // resolve_and_record does (so a `--trust` granted after a parallel resolve // recorded untrusted is still honored), and reuse the gathered inputs only // for the recompute — keeping the DECISIONS cache contract identical to // resolve_and_record without repeating the expensive repo_configs scan. resolve_and_record_inner( &key, || TrustStore::load().is_trusted(&key), || compute_from_inputs(&inputs, feature, &key, false), ) } /// Cache-reconciling core of [`resolve_and_record`], split out so the /// invalidation path is testable without the process-global trust store. /// /// - A cached **grant** (`Some(true)`) is durable and short-circuits — neither /// `store_trusted` nor `recompute` runs. /// - A cached **untrusted** verdict (`Some(false)`) is re-checked via /// `store_trusted`: a `kigi --trust` grant issued AFTER this workspace was /// first resolved writes the store, so honor it on the next session without a /// restart. Without this re-read a long-lived leader would mask the grant. /// - An **unrecorded** key (`None`) does a full `recompute`, which reports /// `(allowed, durable)`; the verdict is recorded ONLY when `durable`. The /// provisional "no repo configs" allow is non-durable, so it stays unrecorded /// and every resolve re-checks for code-exec config that appeared after the /// folder was first opened (TOCTOU). fn resolve_and_record_inner( key: &Path, store_trusted: impl FnOnce() -> bool, recompute: impl FnOnce() -> (bool, bool), ) -> bool { // Copy out of the lock so `record` below can re-acquire it (parking_lot // mutexes are not re-entrant). let cached = DECISIONS.lock().get(key).copied(); match cached { Some(true) => true, Some(false) => { if store_trusted() { record(key, true); true } else { false } } None => { // Record only durable verdicts; a provisional no-configs allow is // left uncached so the next resolve re-checks `repo_configs_present`. let (allowed, durable) = recompute(); if durable { record(key, allowed); } allowed } } } /// Returns `(allowed, durable)`. `durable` is whether the verdict may be cached. /// Two allows are NON-durable: (1) the "no repo configs" allow, because /// repo-local code-exec config can appear after this resolve (git pull / agent /// write) and caching that provisional grant would let a later `/hooks reload` or /// new session run the new code with no trust decision (TOCTOU); and (2) the /// unrecordable-key allow (cwd is $HOME / fs-root), which the store can never /// persist anyway. Store-trusted, feature-off, and an accepted prompt are /// durable; an untrusted verdict is recorded so a later `--trust` grant can /// reconcile it (see [`resolve_and_record_inner`]). fn compute( cwd: &Path, key: &Path, remote: Option<&RemoteSettings>, allow_prompt: bool, ) -> (bool, bool) { let feature = feature_enabled(remote); let inputs = decide_inputs(cwd, key); compute_from_inputs(&inputs, feature, key, allow_prompt) } /// [`compute`] split at the gather: derive `(allowed, durable)` from an /// already-gathered [`DecideInputs`] so a caller needing more than one verdict /// (see [`resolve_launch_dir_trust`]) pays for the expensive `decide_inputs` /// gather (store read + `repo_configs_present` scan) only ONCE. fn compute_from_inputs( inputs: &DecideInputs, feature: bool, key: &Path, allow_prompt: bool, ) -> (bool, bool) { match decide(feature, inputs) { TrustOutcome::Trusted => { // Within the Trusted arm the non-durable ("provisional") allows are the // "no repo configs" rule and the unrecordable-key rule (Case 2: cwd is // $HOME / fs-root, which can never be persisted) — both are feature-on // and not store-trusted; feature-off and store-trusted are durable. // Leave the non-durable allows uncached. let durable = !feature || inputs.store_trusted; (true, durable) } TrustOutcome::Prompt if allow_prompt => { if prompt_for_trust(key) { // Reload the store (the inputs gather dropped its copy) to // persist the accepted prompt grant. persist_trust(&mut TrustStore::load(), key); (true, true) } else { (false, true) } } // Untrusted, OR interactive where prompting is unsafe here (TUI owns // stdin) — the agent-`initialize` path owns the launch-dir prompt. Both // resolve fail-closed. TrustOutcome::Untrusted | TrustOutcome::Prompt => (false, true), } } /// PROJECT-scoped MCP server display names for `cwd` — the names dropped from a /// merged server list when the workspace is untrusted. /// /// SINGLE SOURCE OF TRUTH for "project-scoped MCP names" across ALL gate sites /// (session merge, the session-less agent pool, `kigi mcp doctor`). It MUST /// enumerate every project MCP source the loaders read; adding a new repo-local /// MCP source without extending this fn silently re-opens the gate (guarded by /// `project_scoped_mcp_names_cover_every_source`). /// /// Name-based (not `ConfigSource`-based) ON PURPOSE: it is the one primitive /// that works for BOTH the sourced session merge AND the flat `load_mcp_servers` /// agent-pool/doctor paths, which carry no `ConfigSource`. Names use the same /// identity the merge dedups on ([`mcp_server_name`]). /// /// Sources: project `.kigi/config.toml [mcp_servers]` (NOT the user-tier global /// config), project `.mcp.json` (`cwd` up to the repo root, never `$HOME`), /// project `.cursor/mcp.json`, and `~/.claude.json projects..mcpServers`. /// /// Edge case: a name declared in BOTH a project config and the global /// `~/.kigi/config.toml` is dropped when untrusted. This is intended — untrusted /// project content must not influence the command spawned for a shared name. pub fn project_scoped_mcp_names(cwd: &Path) -> HashSet { let mut names = HashSet::new(); // `.kigi/config.toml [mcp_servers]` entries tagged project (the loader's key // is the display name, matching `mcp_server_name` of the merged server). for (name, (_cfg, scope)) in crate::util::config::load_mcp_server_configs_with_project(cwd) { if scope == MCP_SCOPE_PROJECT { names.insert(name); } } // Project `.mcp.json` (repo-root-down; this loader never reads $HOME). for server in crate::util::config::load_mcp_json_servers(cwd) { names.insert(mcp_server_name(&server).to_string()); } // Project `.cursor/mcp.json` only — `load_cursor_mcp_servers` would also // merge the user-scoped `~/.cursor/mcp.json`, so read the project file // directly instead. for server in crate::util::config::load_mcp_json_file(&cwd.join(".cursor").join("mcp.json")) { names.insert(mcp_server_name(&server).to_string()); } // `~/.claude.json projects..mcpServers` object keys. if let Some(claude_names) = claude_project_mcp_names(cwd) { names.extend(claude_names); } names } /// Drop repo-local (project-scoped) MCP servers from a merged server list when /// `cwd`'s workspace is untrusted. No-op when project scope is allowed. Mirrors /// [`filter_untrusted_project_lsp`]. /// /// Matches on display name ([`mcp_server_name`]) — the same identity the merge /// dedups and the disabled/allowlist filters use — rather than the URL/key, so a /// project server is dropped regardless of transport. Because the match is by /// name, a server from ANY tier (client/plugin/user/managed) whose name COLLIDES /// with a project-declared name is ALSO dropped when untrusted: an untrusted repo /// must not influence the command spawned for that name (see /// [`project_scoped_mcp_names`]). Servers with no project-name collision are kept. pub fn filter_untrusted_project_mcp( cwd: &Path, merged: Vec, ) -> Vec { if project_scope_allowed(cwd) { return merged; } let project = project_scoped_mcp_names(cwd); merged .into_iter() .filter(|server| { let name = mcp_server_name(server); if project.contains(name) { tracing::warn!( server = %name, "folder untrusted: skipping repo-local (project-scoped) MCP server" ); false } else { true } }) .collect() } /// Drop repo-local (project-scoped) LSP servers from a sourced LSP map when /// `cwd`'s workspace is untrusted. Mirrors the MCP session gate; user- and /// plugin-scoped servers are retained. No-op when project scope is allowed. /// /// Thin `cwd`→verdict wrapper over the shared /// [`kigi_tools::implementations::lsp::config::filter_project_lsp_when_untrusted`] /// predicate, so Site B and the workspace build path share one gate. pub fn filter_untrusted_project_lsp( cwd: &Path, sourced: std::collections::BTreeMap< String, ( kigi_tools::implementations::lsp::config::LspServerConfig, kigi_tools::types::config_source::ConfigSource, ), >, ) -> std::collections::BTreeMap { kigi_tools::implementations::lsp::config::filter_project_lsp_when_untrusted( sourced, project_scope_allowed(cwd), ) } #[cfg(test)] mod tests { use super::*; // Used only by the consume-side regression test below; imported here (not at // module scope) so the non-test build doesn't carry an unused import. use kigi_workspace::folder_trust::repo_configs_present; /// A `git init`'d temp dir so `find_mcp_json_files` / `find_project_configs` /// (which discover the enclosing repo and walk to its root) are bounded to /// the temp dir instead of any ancestor repo the system temp dir lives in. fn repo_tmp() -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); git2::Repository::init(tmp.path()).unwrap(); tmp } /// Simulate a release-stamped build so the folder-trust gate engages: an /// unstamped local/dev build auto-trusts and never gates/persists. Hold the /// returned guard for the test body (drop restores the prior value). fn simulate_release_build() -> EnvGuard { EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0.0-sim") } #[test] fn record_and_lookup_round_trip() { // `repo_tmp` git-inits the dir so `workspace_key` yields a unique key // (it returns the git-repo root, which would otherwise collapse to a // shared ambient root if `$TMPDIR` is inside a checkout). The // `DECISIONS` map is process-global, so unique keys keep parallel tests // from clobbering each other's recorded decisions. let tmp = repo_tmp(); let key = tmp.path().to_path_buf(); // A fresh, never-recorded key re-resolves fail-closed and is allowed here // (inert local build / no repo configs — never a durable default-open). assert!(project_scope_allowed(&key)); record(&workspace_key(&key), false); assert!(!project_scope_allowed(&key)); } #[test] #[serial_test::serial] fn revoke_folder_trust_downgrades_cache() { let _sim = simulate_release_build(); // A mid-session untrust of a TRUSTED folder must take effect immediately: // revoke downgrades the in-process cache so `project_scope_allowed` flips // to false at once (a cached grant would otherwise short-circuit // `resolve_and_record`). Seed the trust store so `was_trusted` is genuinely // true; KIGI_SHARE_DIR-isolated so the seed can't touch the real user file and // `#[serial]` because KIGI_SHARE_DIR is process-global. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let tmp = repo_tmp(); let mut store = TrustStore::load(); store.set_trusted(&workspace_key(tmp.path())).unwrap(); record(&workspace_key(tmp.path()), true); assert!(project_scope_allowed(tmp.path())); assert!( revoke_folder_trust(tmp.path()), "a store-trusted folder must report was_trusted=true" ); assert!( !project_scope_allowed(tmp.path()), "cache must be downgraded so untrust is immediate" ); } #[test] #[serial_test::serial] fn revoke_never_trusted_folder_writes_no_deny_and_preserves_cascade() { let _sim = simulate_release_build(); // The actual bug fix: revoking a NEVER-trusted child must write no explicit // child STORE deny (returning false) so a later ancestor grant still // cascades to the child — a spurious child `set_untrusted` would win // most-specific and break the cascade. It must STILL downgrade the // in-process cache, though, so a cached storeless grant cannot survive a // mid-session untrust. KIGI_SHARE_DIR-isolated so the grant writes to a temp // store; `#[serial]` because KIGI_SHARE_DIR is global. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); // Distinct git roots for parent/child so `workspace_key` does not collapse // them onto one key (the child's own `.git` stops discovery at the child). let parent = repo_tmp(); let child = parent.path().join("child"); std::fs::create_dir_all(&child).unwrap(); git2::Repository::init(&child).unwrap(); // Seed a cached grant with NO backing store record (e.g. a kill-switch / // feature-off `compute` allow), so the cache downgrade is observable below. record(&workspace_key(&child), true); // Revoking the never-trusted child reports false and writes no store deny. assert!( !revoke_folder_trust(&child), "revoking a never-trusted folder must return false" ); // The in-process cache is still downgraded so the untrust is immediate even // for the storeless cached grant. assert!( !project_scope_allowed(&child), "revoke must downgrade the in-process cache even for a never-trusted folder" ); // A subsequent ancestor grant must still cascade to the child — proving // the revoke did not poison the store with a most-specific child deny. let mut store = TrustStore::load(); store.set_trusted(&workspace_key(parent.path())).unwrap(); assert!( TrustStore::load().is_trusted(&workspace_key(&child)), "ancestor grant must cascade to a child that was only revoked-when-untrusted" ); } #[test] #[serial_test::serial] fn revoke_ancestor_cascade_trusted_child_writes_explicit_untrust() { let _sim = simulate_release_build(); // Revoke on a child trusted ONLY via an ancestor cascade (no direct child // grant) must report was_trusted=true and actually untrust the child: it // writes an explicit child deny (overriding the cascade) and downgrades // the cache. KIGI_SHARE_DIR-isolated so the grant writes to a temp store; // `#[serial]` because KIGI_SHARE_DIR is process-global. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); // Distinct git roots so `workspace_key` keeps parent/child as separate // keys (the child's own `.git` stops discovery at the child). let parent = repo_tmp(); let child = parent.path().join("child"); std::fs::create_dir_all(&child).unwrap(); git2::Repository::init(&child).unwrap(); // Trust the parent only; the child inherits trust via the cascade. let mut store = TrustStore::load(); store.set_trusted(&workspace_key(parent.path())).unwrap(); assert!( TrustStore::load().is_trusted(&workspace_key(&child)), "child must be trusted via the ancestor cascade before revoke" ); // Revoking the cascade-trusted child reports was_trusted=true, then the // child is untrusted: an explicit child deny overrides the cascade and // the in-process cache is downgraded. assert!( revoke_folder_trust(&child), "a cascade-trusted child must report was_trusted=true" ); assert!( !TrustStore::load().is_trusted(&workspace_key(&child)), "revoke must write an explicit child untrust that overrides the cascade" ); assert!( !project_scope_allowed(&child), "revoke must downgrade the in-process cache for the child" ); } #[test] #[serial_test::serial] fn revoke_on_unrecordable_home_root_records_no_deny() { let _sim = simulate_release_build(); // cwd == $HOME (git-inited so `workspace_key` resolves the home root, // which the store refuses to record): revoke must NOT seed a cache deny. // decide() always trusts an unrecordable root and no grant/store/prompt // could ever lift the deny, so the gate must keep allowing after an // untrust click. HOME overridden so workspace_key sees the tempdir as // home; KIGI_SHARE_DIR-isolated store; KIGI_FOLDER_TRUST unset so the // default-on flag applies. let home = tempfile::tempdir().unwrap(); let _home = EnvGuard::set("HOME", home.path()); let kigi_home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", kigi_home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); git2::Repository::init(home.path()).unwrap(); // Repo-local code-exec config, so the final allow is the unrecordable-key // rule at work (a recordable key with configs + empty store would deny). std::fs::create_dir_all(home.path().join(".kigi").join("hooks")).unwrap(); assert!( !revoke_folder_trust(home.path()), "an unrecordable root reports was_trusted=false" ); assert!( DECISIONS.lock().get(&workspace_key(home.path())).is_none(), "revoke must record no cache deny for an unrecordable root" ); assert!( project_scope_allowed(home.path()), "$HOME must stay allowed after a revoke attempt" ); } #[test] #[serial_test::serial] fn envrc_gate_drops_untrusted_then_loads_when_store_trusted() { let _sim = simulate_release_build(); // The `.envrc` load sites gate on the folder-trust verdict: an // `.envrc`-only untrusted clone resolves false (so the call site loads an // empty env), while a store-trusted folder resolves true and the loader // actually reads `.envrc`. KIGI_SHARE_DIR-isolated so the trust store is empty; // KIGI_FOLDER_TRUST unset so the default-on feature flag applies. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); std::fs::write(tmp.path().join(".envrc"), "export GATED_ENVRC=1\n").unwrap(); // Untrusted: the gate verdict is false => the call site skips loading. assert!( !resolve_and_record(tmp.path(), None, false), "untrusted `.envrc` clone must gate the load" ); // Store-trust the folder => the verdict reconciles to true and the loader runs. let mut store = TrustStore::load(); store.set_trusted(&workspace_key(tmp.path())).unwrap(); assert!(resolve_and_record(tmp.path(), None, false)); let env = kigi_workspace::envrc::load_envrc_or_empty(tmp.path()); assert_eq!( env.get("GATED_ENVRC"), Some(&"1".to_string()), "trusted folder must load `.envrc`" ); } #[test] #[serial_test::serial] fn claude_env_gate_drops_project_env_when_untrusted() { let _sim = simulate_release_build(); // The `.claude/settings.json` env load site mirrors // `load_claude_env_with_project(cwd, project_scope_allowed(cwd))`: an // untrusted clone's repo-tree env (which would feed BASH_ENV / // GIT_SSH_COMMAND / … to every subprocess) is dropped; a store-trusted // folder merges it. KIGI_SHARE_DIR-isolated so the trust store is empty; // KIGI_FOLDER_TRUST unset so the default-on feature flag applies. use kigi_workspace::permission::claude_settings::load_claude_env_with_project; let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); let claude = tmp.path().join(".claude"); std::fs::create_dir_all(&claude).unwrap(); std::fs::write( claude.join("settings.json"), r#"{"env": {"REPO_TREE_ENV_GATED": "1"}}"#, ) .unwrap(); // Untrusted: verdict false => the repo-tree env is dropped. assert!(!resolve_and_record(tmp.path(), None, false)); let untrusted = load_claude_env_with_project(tmp.path(), resolve_and_record(tmp.path(), None, false)); assert!( !untrusted.contains_key("REPO_TREE_ENV_GATED"), "untrusted folder must drop repo-tree .claude env" ); // Store-trust => verdict reconciles to true => repo-tree env merged. let mut store = TrustStore::load(); store.set_trusted(&workspace_key(tmp.path())).unwrap(); assert!(resolve_and_record(tmp.path(), None, false)); let trusted = load_claude_env_with_project(tmp.path(), resolve_and_record(tmp.path(), None, false)); assert_eq!( trusted.get("REPO_TREE_ENV_GATED"), Some(&"1".to_string()), "trusted folder must merge repo-tree .claude env" ); } #[test] #[serial_test::serial] fn claude_env_gate_drops_subdir_project_env_when_untrusted() { let _sim = simulate_release_build(); // RCE regression (subdir bypass): a `.claude/settings.json` with `env` in // a SUBDIR — the ONLY repo config — launched from that subdir must flip the // folder untrusted AND have its env dropped. The env loader walks // cwd→repo-root, so detection MUST walk too (a git-root-only probe missed // this). KIGI_SHARE_DIR-isolated so the trust store is empty. use kigi_workspace::permission::claude_settings::load_claude_env_with_project; let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); let subdir = tmp.path().join("sub"); let claude = subdir.join(".claude"); std::fs::create_dir_all(&claude).unwrap(); std::fs::write( claude.join("settings.json"), r#"{"env": {"SUBDIR_REPO_ENV_GATED": "1"}}"#, ) .unwrap(); // Detection walks cwd→root, so the subdir-only `.claude` is detected // and the folder resolves untrusted. assert!( repo_configs_present(&subdir), "subdir .claude/settings.json must be detected" ); assert!(!resolve_and_record(&subdir, None, false)); let untrusted = load_claude_env_with_project(&subdir, resolve_and_record(&subdir, None, false)); assert!( !untrusted.contains_key("SUBDIR_REPO_ENV_GATED"), "untrusted subdir folder must drop its repo-tree .claude env" ); // Granting trust re-enables it (proves the env file is real/loadable). let mut store = TrustStore::load(); store.set_trusted(&workspace_key(&subdir)).unwrap(); assert!(resolve_and_record(&subdir, None, false)); let trusted = load_claude_env_with_project(&subdir, resolve_and_record(&subdir, None, false)); assert_eq!( trusted.get("SUBDIR_REPO_ENV_GATED"), Some(&"1".to_string()), "trusted folder must merge the subdir repo-tree .claude env" ); } #[test] #[serial_test::serial] fn project_agent_inline_hooks_gated_when_untrusted_but_user_kept() { let _sim = simulate_release_build(); // A cwd-discovered PROJECT agent's inline `hooks:` is gated on folder-trust // (it can SHADOW a built-in subagent => near-auto RCE); a user/built-in // agent's hooks are kept. Exercises real discovery + the exact call-site // predicate used at mvp_agent/subagent. KIGI_SHARE_DIR-isolated (empty store). use kigi_agent::config::AgentScope; let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); let agents = tmp.path().join(".kigi").join("agents"); std::fs::create_dir_all(&agents).unwrap(); // Shadows the built-in `explore` subagent and carries a command hook. std::fs::write( agents.join("explore.md"), "---\nname: explore\ndescription: x\nhooks:\n PreToolUse:\n - hooks:\n - type: command\n command: \"true\"\n---\nbody\n", ) .unwrap(); let def = kigi_agent::discovery::by_name_in_cwd("explore", tmp.path()) .expect("project agent must be discovered"); assert_eq!(def.scope, AgentScope::Project); assert!(def.hooks.is_some(), "project agent must carry inline hooks"); // Exercise the REAL shared predicate both append sites use (not a copy). let allowed = |scope: AgentScope| { agent_inline_hooks_allowed(scope, || project_scope_allowed(tmp.path())) }; // Untrusted: the project agent's inline hooks are dropped... assert!( !allowed(def.scope), "untrusted project agent inline hooks must be gated" ); // ...while user/built-in agents (not cwd-sourced) keep their hooks. assert!(allowed(AgentScope::User)); assert!(allowed(AgentScope::BuiltIn)); // Grant trust + reconcile the cache (as the post-grant reload flow does) // => the project agent's inline hooks are appended. let mut store = TrustStore::load(); store.set_trusted(&workspace_key(tmp.path())).unwrap(); resolve_and_record(tmp.path(), None, false); assert!( allowed(def.scope), "trusted project agent inline hooks must be appended" ); } use kigi_test_support::EnvGuard; #[test] #[serial_test::serial] fn project_scope_allowed_denies_untrusted_repo_with_configs() { // Fail-closed (the dangerous case): a release-stamped build with the // feature on by default, an untrusted folder that ships repo-local // code-exec config (here `.kigi/hooks`), and no store grant must be // DENIED — even though no verdict was recorded first (the gate re-resolves // fail-closed rather than defaulting open). KIGI_SHARE_DIR-isolated (empty // store); KIGI_FOLDER_TRUST unset so the default-on flag applies. let _sim = simulate_release_build(); let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); std::fs::create_dir_all(tmp.path().join(".kigi").join("hooks")).unwrap(); assert!( !project_scope_allowed(tmp.path()), "untrusted folder with repo configs must be denied (fail-closed)" ); } #[test] #[serial_test::serial] fn project_scope_allowed_allows_repo_without_configs() { // The over-deny guard: a folder with NO repo-local code-exec config has // nothing to gate, so it must be ALLOWED even though its (provisional) // Trusted verdict is never cached — a naive `.unwrap_or(false)` cache peek // would wrongly deny it. Release-stamped + KIGI_SHARE_DIR-isolated so the // verdict comes from `decide` rule 4 (no repo configs), not the inert // short-circuit. let _sim = simulate_release_build(); let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); assert!( project_scope_allowed(tmp.path()), "a folder with no repo configs must be allowed (no over-deny)" ); } #[test] #[serial_test::serial] fn project_scope_allowed_allows_store_trusted_repo() { // A folder the user explicitly trusted is ALLOWED even with repo-local // configs present. KIGI_SHARE_DIR-isolated so the seeded store is the temp one; // KIGI_FOLDER_TRUST unset so the default-on flag applies. let _sim = simulate_release_build(); let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); std::fs::create_dir_all(tmp.path().join(".kigi").join("hooks")).unwrap(); let mut store = TrustStore::load(); store.set_trusted(&workspace_key(tmp.path())).unwrap(); assert!( project_scope_allowed(tmp.path()), "a store-trusted folder must be allowed" ); } #[test] #[serial_test::serial] fn project_scope_allowed_allows_inert_local_build() { // On a local/dev build the whole feature is inert (auto-trust): a folder // with repo-local configs and an empty store is still ALLOWED. Assert only // when compiled unstamped (mirrors the inert tests elsewhere), with // KIGI_TEST_VERSION unset so `is_local_build()` is genuinely true. let _unset_ver = EnvGuard::unset(kigi_version::TEST_VERSION_ENV); if option_env!("KIGI_VERSION").is_some() { // a release-stamped test binary is not a local build return; } let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let tmp = repo_tmp(); std::fs::create_dir_all(tmp.path().join(".kigi").join("hooks")).unwrap(); assert!( project_scope_allowed(tmp.path()), "inert local/dev build must allow project scope even with configs" ); } #[test] #[serial_test::serial] fn project_scope_allowed_denies_untrusted_plugin_only_repo() { // A plugin-only untrusted repo (just `.kigi/plugins//`, no // hooks/MCP/LSP, no store grant) is repo-controlled code-exec and must be // DENIED — the verdict the shell plugin call sites feed into // discover_plugins/build_for_cwd/reload. KIGI_SHARE_DIR-isolated (empty store); // KIGI_FOLDER_TRUST unset so the default-on flag applies. let _sim = simulate_release_build(); let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); std::fs::create_dir_all(tmp.path().join(".kigi").join("plugins").join("evil")).unwrap(); assert!( !project_scope_allowed(tmp.path()), "plugin-only untrusted repo must be denied" ); } #[test] #[serial_test::serial] fn kill_switch_allows_untrusted_repo_after_authoritative_resolve() { // Regression (chat/load-path kill-switch): an untrusted folder WITH repo // configs under a remote kill-switch (folder_trust_enabled = Some(false)) // must resolve ALLOWED. The session spawn path resolves once with the real // RemoteSettings before any gate read, so the gate cache-hits that verdict. // KIGI_SHARE_DIR-isolated (empty store); KIGI_FOLDER_TRUST unset so the kill-switch // is the only signal. let _sim = simulate_release_build(); let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let remote = RemoteSettings { folder_trust_enabled: Some(false), ..Default::default() }; let tmp = repo_tmp(); std::fs::create_dir_all(tmp.path().join(".kigi").join("hooks")).unwrap(); assert!( resolve_and_record(tmp.path(), Some(&remote), false), "kill-switch (feature off) must resolve trusted even with repo configs" ); assert!( project_scope_allowed(tmp.path()), "gate must allow a kill-switched folder once the authoritative resolve ran" ); // Contrast: a cold `remote = None` gate read (no prior authoritative resolve) // misses the kill-switch and denies the same scenario — the exact gap the // up-front spawn resolve closes for chat/load sessions. let cold = repo_tmp(); std::fs::create_dir_all(cold.path().join(".kigi").join("hooks")).unwrap(); assert!( !project_scope_allowed(cold.path()), "cold remote=None gate read denies a kill-switched folder (regression contrast)" ); } #[test] #[serial_test::serial] fn build_for_cwd_with_trust_verdict_gates_active_project_plugin() { let _sim = simulate_release_build(); // Pins the SHELL plugin wiring end-to-end: the call-site expression // `build_for_cwd(cwd, &cfg, dirs, )` must keep an // ENABLED project plugin OUT of `active_plugins()` while the folder is // untrusted, and let it in after `grant_folder_trust`. The // verdict/discovery/registry unit tests alone do NOT catch a silent // un-gating here. // // KIGI_SHARE_DIR-isolated so both the folder-trust store and the plugin trust // store start empty (deterministic untrusted); KIGI_FOLDER_TRUST unset so // the default-on flag applies; `#[serial]` because both are process-global. use kigi_agent::plugins::discovery::DiscoveryConfig; use kigi_agent::plugins::{PluginRegistry, SharedPluginRegistryHandle}; let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); // A project plugin. Project scope is default-disabled, so name it in the // `enabled` list to isolate the TRUST gate (not the enable gate). let plugin = tmp.path().join(".kigi").join("plugins").join("trustgate"); std::fs::create_dir_all(&plugin).unwrap(); std::fs::write(plugin.join("plugin.json"), r#"{"name":"trustgate"}"#).unwrap(); let cfg = DiscoveryConfig { enabled: vec!["trustgate".to_string()], ..Default::default() }; let handle = SharedPluginRegistryHandle::new(None, vec![]); // Name-specific so ambient user plugins on the test host are irrelevant. let active_has = |reg: Option>| { reg.is_some_and(|r| r.active_plugins().iter().any(|p| p.name == "trustgate")) }; // Untrusted: the verdict is false => the plugin is still discovered but // not trusted, so it is absent from `active_plugins()`. let untrusted = handle.build_for_cwd( tmp.path(), &cfg, &[], resolve_and_record(tmp.path(), None, false), ); assert!( !active_has(untrusted), "untrusted folder: enabled project plugin must be gated out of active_plugins()" ); // Grant trust, recompute the same expression => the plugin becomes active. grant_folder_trust(tmp.path()); let trusted = handle.build_for_cwd( tmp.path(), &cfg, &[], resolve_and_record(tmp.path(), None, false), ); assert!( active_has(trusted), "granted folder: enabled project plugin must appear in active_plugins()" ); } #[test] #[serial_test::serial] fn discover_hooks_gates_then_loads_project_hook_via_trust_verdict() { let _sim = simulate_release_build(); // End-to-end load path: the folder-trust verdict threaded into `discover_hooks` // excludes a repo-local project hook while untrusted, and includes it after the // folder is granted trust — the path where the regression historically re-opened. // KIGI_SHARE_DIR-isolated so the grant writes to a temp store; KIGI_FOLDER_TRUST unset // so the default-on flag applies. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let tmp = repo_tmp(); let hooks_dir = tmp.path().join(".kigi").join("hooks"); std::fs::create_dir_all(&hooks_dir).unwrap(); // Top-level `{"hooks":{...}}` wrapper; no matcher => match-all. The parsed // spec name is `:PreToolUse[..]`, so the file stem identifies it. std::fs::write( hooks_dir.join("trust_load_gate.json"), r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"true"}]}]}}"#, ) .unwrap(); // Discovery prefixes project specs with `project/` and parse names them // `:[..]`, so the unique stem appears mid-name; // `contains` matches it without coupling to the full name format. let has_project_hook = |reg: &kigi_hooks::discovery::HookRegistry| { reg.all_hooks() .iter() .any(|h| h.name.contains("trust_load_gate")) }; // Untrusted: verdict false => discovery omits the project hook. let untrusted = resolve_and_record(tmp.path(), None, false); assert!(!untrusted, "untrusted repo must resolve the gate false"); // Mirror the production startup/reload path via the single load entry point. let git_root = kigi_workspace::session::git::find_git_root_from_path(tmp.path()).ok(); let (reg, _errs) = crate::util::hooks::discover_hooks( git_root.as_deref(), &kigi_tools::types::compat::CompatConfig::default(), untrusted, ); assert!( !has_project_hook(®), "untrusted: repo-local project hook must be gated out of discovery" ); // Grant trust, re-resolve => discovery loads the project hook. grant_folder_trust(tmp.path()); let trusted = resolve_and_record(tmp.path(), None, false); assert!(trusted, "granted repo must resolve the gate true"); let (reg, _errs) = crate::util::hooks::discover_hooks( git_root.as_deref(), &kigi_tools::types::compat::CompatConfig::default(), trusted, ); assert!( has_project_hook(®), "trusted: repo-local project hook must load" ); } #[test] fn filter_untrusted_project_lsp_drops_only_project() { use kigi_tools::implementations::lsp::config::LspServerConfig; use kigi_tools::types::config_source::ConfigSource; use std::collections::BTreeMap; fn sourced() -> BTreeMap { let mut m = BTreeMap::new(); m.insert( "proj".to_string(), ( LspServerConfig::default(), ConfigSource::Project { path: PathBuf::from("/repo/.kigi/lsp.json"), }, ), ); m.insert( "usr".to_string(), ( LspServerConfig::default(), ConfigSource::User { path: PathBuf::from("/home/.kigi/lsp.json"), }, ), ); m } // Untrusted workspace: only the project-scoped server is dropped. // `repo_tmp` git-inits so `workspace_key` yields a unique per-dir key // even when `$TMPDIR` itself lives inside a git checkout. let untrusted = repo_tmp(); record_for_test(untrusted.path(), false); let kept = filter_untrusted_project_lsp(untrusted.path(), sourced()); assert_eq!(kept.len(), 1); assert!(kept.contains_key("usr")); assert!(!kept.contains_key("proj")); // Trusted workspace (a different temp-dir key): both are retained. let trusted = repo_tmp(); record_for_test(trusted.path(), true); let kept = filter_untrusted_project_lsp(trusted.path(), sourced()); assert_eq!(kept.len(), 2); assert!(kept.contains_key("usr")); assert!(kept.contains_key("proj")); } #[test] fn load_servers_sourced_tags_project_lsp_json() { use kigi_tools::implementations::lsp::config::load_servers_with_plugins_sourced; use kigi_tools::types::config_source::ConfigSource; // A `/.kigi/lsp.json` server must be tagged `Project` so the gate // can distinguish it from user/plugin servers. Asserts on the specific // key, so any real `~/.kigi/lsp.json` on the test host is irrelevant. let tmp = repo_tmp(); let kigi = tmp.path().join(".kigi"); std::fs::create_dir_all(&kigi).unwrap(); std::fs::write(kigi.join("lsp.json"), r#"{"projlsp": {"command": "true"}}"#).unwrap(); let sourced = load_servers_with_plugins_sourced(tmp.path(), &[], &[], &[], &[]); let (_, source) = sourced.get("projlsp").expect("project server present"); assert!( matches!(source, ConfigSource::Project { .. }), "project lsp.json must be tagged Project; got {source:?}" ); } #[test] fn untrusted_workspace_drops_loaded_project_lsp() { use kigi_tools::implementations::lsp::config::load_servers_with_plugins_sourced; // End-to-end of the load-site gate (Sites A/B): a project server loaded // from `/.kigi/lsp.json` is dropped once the workspace is untrusted. let tmp = repo_tmp(); let kigi = tmp.path().join(".kigi"); std::fs::create_dir_all(&kigi).unwrap(); std::fs::write(kigi.join("lsp.json"), r#"{"projlsp": {"command": "true"}}"#).unwrap(); let sourced = load_servers_with_plugins_sourced(tmp.path(), &[], &[], &[], &[]); assert!( sourced.contains_key("projlsp"), "loader must see the project server" ); record_for_test(tmp.path(), false); let kept = filter_untrusted_project_lsp(tmp.path(), sourced); assert!( !kept.contains_key("projlsp"), "untrusted folder must drop the project-scoped LSP server" ); } fn http(name: &str) -> acp::McpServer { acp::McpServer::Http( acp::McpServerHttp::new(name.to_string(), format!("https://example.com/{name}")) .headers(vec![]), ) } /// A git-init'd repo declaring two project-scoped MCP servers: `projjson` /// (`.mcp.json`) and `projtoml` (`.kigi/config.toml [mcp_servers]`). fn repo_with_project_mcp() -> tempfile::TempDir { let tmp = repo_tmp(); std::fs::write( tmp.path().join(".mcp.json"), r#"{"mcpServers": {"projjson": {"url": "https://proj.example.com/mcp"}}}"#, ) .unwrap(); let kigi = tmp.path().join(".kigi"); std::fs::create_dir_all(&kigi).unwrap(); std::fs::write( kigi.join("config.toml"), "[mcp_servers.projtoml]\nurl = \"https://projtoml.example.com/mcp\"\n", ) .unwrap(); tmp } /// Pins the three known repo-local FILE sources of /// [`project_scoped_mcp_names`]: a project server declared in each of /// `.kigi/config.toml`, `.mcp.json`, and `.cursor/mcp.json` must appear in /// the returned set, catching a REGRESSION that drops one of them. It cannot /// catch a brand-new source TYPE added only to a loader — the prominent /// single-source-of-truth doc on `project_scoped_mcp_names` is that guard. /// (`~/.claude.json` is excluded — it lives under `$HOME` and a test must not /// clobber the real user file; its keys are covered by the shared reader.) #[test] fn project_scoped_mcp_names_cover_every_source() { let tmp = repo_tmp(); let kigi = tmp.path().join(".kigi"); std::fs::create_dir_all(&kigi).unwrap(); std::fs::write( kigi.join("config.toml"), "[mcp_servers.cfgsrv]\nurl = \"https://cfg.example.com/mcp\"\n", ) .unwrap(); std::fs::write( tmp.path().join(".mcp.json"), r#"{"mcpServers": {"jsonsrv": {"url": "https://json.example.com/mcp"}}}"#, ) .unwrap(); let cursor = tmp.path().join(".cursor"); std::fs::create_dir_all(&cursor).unwrap(); std::fs::write( cursor.join("mcp.json"), r#"{"mcpServers": {"cursorsrv": {"url": "https://cursor.example.com/mcp"}}}"#, ) .unwrap(); let names = project_scoped_mcp_names(tmp.path()); for expected in ["cfgsrv", "jsonsrv", "cursorsrv"] { assert!( names.contains(expected), "project_scoped_mcp_names missing {expected:?} — a project MCP source is no longer gated; got {names:?}" ); } } #[test] fn filter_untrusted_project_mcp_drops_only_project() { let merged = || { vec![ http("projjson"), http("projtoml"), http("client"), http("global"), ] }; // Untrusted: both project-declared servers are dropped; a client-supplied // and a user/global server (neither in a project config) are retained. let untrusted = repo_with_project_mcp(); record_for_test(untrusted.path(), false); let kept = filter_untrusted_project_mcp(untrusted.path(), merged()); let names: HashSet<&str> = kept.iter().map(mcp_server_name).collect(); assert_eq!(names, ["client", "global"].into_iter().collect()); // Trusted: every server is retained even with project configs present. let trusted = repo_with_project_mcp(); record_for_test(trusted.path(), true); let kept = filter_untrusted_project_mcp(trusted.path(), merged()); assert_eq!(kept.len(), 4); } #[test] fn cached_grant_short_circuits_without_store_read() { let tmp = repo_tmp(); let key = workspace_key(tmp.path()); record(&key, true); // A cached grant must consult neither the store nor a recompute. let allowed = resolve_and_record_inner( &key, || panic!("store must not be read for a cached grant"), || panic!("must not recompute for a cached grant"), ); assert!(allowed); } #[test] fn cached_untrusted_is_upgraded_by_a_later_grant() { let tmp = repo_tmp(); let key = workspace_key(tmp.path()); record(&key, false); assert!(!project_scope_allowed(tmp.path())); // Simulate a `kigi --trust` grant landing in the store after the // untrusted verdict was cached: the re-read sees trusted, so the next // resolve upgrades the cache without a process restart. let allowed = resolve_and_record_inner( &key, || true, || panic!("must not recompute when reconciling from the store"), ); assert!(allowed); // The cheap gate the merge consults now allows (cache refreshed). assert!(project_scope_allowed(tmp.path())); } #[test] fn cached_untrusted_stays_untrusted_when_store_still_denies() { let tmp = repo_tmp(); let key = workspace_key(tmp.path()); record(&key, false); let allowed = resolve_and_record_inner(&key, || false, || panic!("must not recompute when cached")); assert!(!allowed); assert!(!project_scope_allowed(tmp.path())); } #[test] fn unrecorded_key_recomputes_then_caches() { let tmp = repo_tmp(); let key = workspace_key(tmp.path()); // No prior record → recompute and record the (durable untrusted) verdict. let allowed = resolve_and_record_inner( &key, || panic!("store re-read is only for a cached untrusted verdict"), || (false, true), ); assert!(!allowed); assert!(!project_scope_allowed(tmp.path())); // Now cached untrusted: a later resolve re-reads the store (still denies). let allowed = resolve_and_record_inner(&key, || false, || panic!("must not recompute when cached")); assert!(!allowed); } #[test] fn explicit_child_untrust_survives_reload_despite_ancestor_trust() { // Untrust-undone-on-reload: an ancestor is trusted and the child is // explicitly untrusted, so a reload must NOT re-promote the child. revoke // downgrades the cache to untrusted; the reconcile re-reads the store, // which now honors the most-specific (child) decision and stays untrusted // — the ancestor's cascade no longer undoes the explicit untrust. let tmp = tempfile::tempdir().unwrap(); let store_path = tmp.path().join(kigi_workspace::trust::TRUST_FILE_NAME); let parent = tmp.path().join("parent"); let child = parent.join("child"); std::fs::create_dir_all(&child).unwrap(); let mut store = TrustStore::load_from(store_path); store.set_trusted(&parent).unwrap(); store.set_untrusted(&child).unwrap(); // revoke downgraded the in-process cache for the child. record(&child, false); let allowed = resolve_and_record_inner( &child, || store.is_trusted(&child), || panic!("must not recompute when reconciling a cached untrusted verdict"), ); assert!( !allowed, "explicit child untrust must survive the ancestor's trust on reload" ); } #[test] #[serial_test::serial] fn no_configs_trust_is_provisional_and_regated_when_configs_appear() { // F5 regression: the "no repo configs => Trusted" verdict is PROVISIONAL, // so it must NOT be cached as a durable grant. Otherwise a clone that is // empty when first resolved, then gains a code-exec config (git pull / // agent write), would short-circuit the stale grant and load+run the new // hooks/plugins ungated on the next /hooks reload or new session (TOCTOU). // // Drives the real `resolve_and_record` + `project_scope_allowed`. Force // the feature on via env (highest precedence) so the test does not depend // on the host's folder-trust config. unsafe { std::env::set_var("KIGI_FOLDER_TRUST", "1") }; // Simulate a release-stamped build: an unstamped local/dev build (as in CI, // no KIGI_VERSION) auto-trusts, so the gate would never engage without this. unsafe { std::env::set_var(kigi_version::TEST_VERSION_ENV, "0.0.0-sim") }; let tmp = repo_tmp(); // Empty repo: nothing to gate => allowed, but left UNRECORDED (provisional). assert!(resolve_and_record(tmp.path(), None, false)); assert!( DECISIONS.lock().get(&workspace_key(tmp.path())).is_none(), "provisional no-configs allow must not be cached as a durable grant" ); // A repo-local code-exec config appears after the first resolve. std::fs::create_dir_all(tmp.path().join(".kigi").join("hooks")).unwrap(); // The next resolve re-checks `repo_configs_present` (no stale grant to // ride) => headless untrusted, so the newly-added hooks are now gated. assert!( !resolve_and_record(tmp.path(), None, false), "configs that appear after the first resolve must be re-checked and gated" ); assert!(!project_scope_allowed(tmp.path())); unsafe { std::env::remove_var(kigi_version::TEST_VERSION_ENV) }; unsafe { std::env::remove_var("KIGI_FOLDER_TRUST") }; } #[test] #[serial_test::serial] fn resolve_launch_dir_trust_matches_resolve_and_record() { // `resolve_launch_dir_trust` derives the launch-dir verdict from one // gather, so it must agree with `resolve_and_record(cwd, None, false)` and // must leave the provisional no-configs grant UNCACHED (the TOCTOU // contract on the shared path). Force the gate on via env (highest // precedence) so the test does not depend on the host config; isolate // KIGI_SHARE_DIR so the store is empty/seeded in temp. `#[serial]` because both // vars are process-global. let _feature = EnvGuard::set("KIGI_FOLDER_TRUST", "1"); let _sim = simulate_release_build(); let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); // (a) No configs => provisional Trusted, NOT cached by the shared path. let empty = repo_tmp(); let lt = resolve_launch_dir_trust(empty.path(), None); assert!( DECISIONS.lock().get(&workspace_key(empty.path())).is_none(), "provisional no-configs allow must not be cached by resolve_launch_dir_trust" ); assert_eq!(lt, resolve_and_record(empty.path(), None, false)); assert!(lt, "no-configs launch dir must be allowed"); // (b) Configs present + untrusted (empty store, headless) => false. let untrusted = repo_tmp(); std::fs::create_dir_all(untrusted.path().join(".kigi").join("hooks")).unwrap(); let lt = resolve_launch_dir_trust(untrusted.path(), None); assert_eq!(lt, resolve_and_record(untrusted.path(), None, false)); assert!(!lt, "untrusted configs launch dir must be denied"); // (c) Configs present + store-trusted => true. let trusted = repo_tmp(); std::fs::create_dir_all(trusted.path().join(".kigi").join("hooks")).unwrap(); let mut store = TrustStore::load(); store.set_trusted(&workspace_key(trusted.path())).unwrap(); let lt = resolve_launch_dir_trust(trusted.path(), None); assert_eq!(lt, resolve_and_record(trusted.path(), None, false)); assert!(lt, "store-trusted launch dir must be allowed"); } #[test] #[serial_test::serial] fn local_build_is_inert_launch_trust_auto_trusts() { // On a local/dev build the whole folder-trust system is inert: an // untrusted repo that HAS repo-local configs (here an `.envrc`) with an // EMPTY store still resolves trusted, `resolve_launch_dir_trust` returns // true, and the `.envrc` loads without any grant. Assert the local branch // ONLY when compiled unstamped (mirrors the workspace // `is_local_build_honors_test_version_override`), with KIGI_TEST_VERSION // unset so `is_local_build()` is genuinely true. KIGI_SHARE_DIR-isolated so the // real store is never touched. let _sim = EnvGuard::unset(kigi_version::TEST_VERSION_ENV); if option_env!("KIGI_VERSION").is_some() { // a release-stamped test binary is not a local build return; } let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let tmp = repo_tmp(); std::fs::write(tmp.path().join(".envrc"), "export LOCAL_BUILD_ENVRC=1\n").unwrap(); // The `.envrc` makes this a gating-eligible repo: on a release build it // would resolve untrusted with an empty store. On a local build it does not. assert!( repo_configs_present(tmp.path()), "the `.envrc` must make this a gating-eligible repo" ); assert!( project_scope_allowed(tmp.path()), "local build: inert gate must auto-trust an untrusted repo that has configs" ); assert!( resolve_launch_dir_trust(tmp.path(), None), "local build: launch-dir verdict must be trusted" ); // The gated `.envrc` load (the call-site contract) runs because the gate // is inert/trusted, so the var is present with no store grant. let env = kigi_workspace::envrc::load_envrc_or_empty(tmp.path()); assert_eq!( env.get("LOCAL_BUILD_ENVRC"), Some(&"1".to_string()), "local build: `.envrc` must load without any store grant" ); } #[test] #[serial_test::serial] fn prompt_warranted_true_for_untrusted_repo_with_configs() { // Feature on (via remote), untrusted (empty store), repo configs present // => the GUI prompt is warranted. KIGI_SHARE_DIR-isolated so the store starts // empty; `#[serial]` because KIGI_SHARE_DIR is process-global. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let tmp = repo_tmp(); std::fs::write(tmp.path().join(".mcp.json"), "{}").unwrap(); // Simulate a release-stamped build so the inert local-build gate is off // and the remote `folder_trust_enabled` flag actually engages. // KIGI_FOLDER_TRUST unset: env outranks the remote flag, so an ambient // opt-out would otherwise false-fail the Prompt assertion. let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0.0-sim"); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let remote = RemoteSettings { folder_trust_enabled: Some(true), ..Default::default() }; assert!(prompt_warranted(tmp.path(), Some(&remote))); } #[test] #[serial_test::serial] fn prompt_warranted_false_when_feature_disabled() { // The remote kill-switch (folder_trust_enabled = Some(false)) disables the // feature even on a release-stamped build, so no prompt is warranted even // with repo configs present. Simulate a release build so the inert // local-build path is not what's under test; KIGI_SHARE_DIR-isolated and // KIGI_FOLDER_TRUST unset so the kill-switch is the only signal. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST"); let _sim = simulate_release_build(); let tmp = repo_tmp(); std::fs::write(tmp.path().join(".mcp.json"), "{}").unwrap(); let remote = RemoteSettings { folder_trust_enabled: Some(false), ..Default::default() }; assert!(!prompt_warranted(tmp.path(), Some(&remote))); } #[test] #[serial_test::serial] fn prompt_warranted_false_when_store_trusted() { // A folder the user already trusted resolves Trusted, not Prompt. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let tmp = repo_tmp(); std::fs::write(tmp.path().join(".mcp.json"), "{}").unwrap(); let mut store = TrustStore::load(); store.set_trusted(&workspace_key(tmp.path())).unwrap(); let remote = RemoteSettings { folder_trust_enabled: Some(true), ..Default::default() }; assert!(!prompt_warranted(tmp.path(), Some(&remote))); } #[test] #[serial_test::serial] fn prompt_warranted_false_without_repo_configs() { // Nothing repo-local to gate => Trusted, not Prompt. let home = tempfile::tempdir().unwrap(); let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path()); let tmp = repo_tmp(); let remote = RemoteSettings { folder_trust_enabled: Some(true), ..Default::default() }; assert!(!prompt_warranted(tmp.path(), Some(&remote))); } #[test] fn detected_config_kinds_summarizes_present_markers() { let tmp = repo_tmp(); std::fs::write(tmp.path().join(".mcp.json"), "{}").unwrap(); std::fs::create_dir_all(tmp.path().join(".kigi").join("hooks")).unwrap(); std::fs::write(tmp.path().join(".kigi").join("lsp.json"), "{}").unwrap(); std::fs::write(tmp.path().join(".envrc"), "export X=1\n").unwrap(); let kinds = detected_config_kinds(tmp.path()); assert!(kinds.contains(&"mcp".to_string())); assert!(kinds.contains(&"hooks".to_string())); assert!(kinds.contains(&"envrc".to_string())); // `lsp` is reported (a real gate reason), NOT filtered out — so an // lsp-only repo never prompts with an empty reason list. assert!(kinds.contains(&"lsp".to_string())); } #[test] fn detected_config_kinds_reports_lsp_only_repo() { // Regression for the "empty configKinds" bug: a repo gated SOLELY by // `.kigi/lsp.json` must still produce a non-empty reason list. let tmp = repo_tmp(); std::fs::create_dir_all(tmp.path().join(".kigi")).unwrap(); std::fs::write(tmp.path().join(".kigi").join("lsp.json"), "{}").unwrap(); let kinds = detected_config_kinds(tmp.path()); assert_eq!(kinds, vec!["lsp".to_string()]); } }