docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -1244,7 +1244,7 @@ mod tests {
|
||||
let old = now_ms() - SESSION_IDLE_PRUNE_MS - 1000;
|
||||
session.idle_since_ms.store(old, Ordering::Relaxed);
|
||||
}
|
||||
// Prune should remove the session and its call_to_session entries.
|
||||
// Prune should drop the session and its call_to_session entries.
|
||||
t.known_sessions();
|
||||
assert!(!t.sessions.contains_key("stale"));
|
||||
// Verify call_to_session was cleaned (no dangling entries).
|
||||
@@ -1449,7 +1449,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── events.jsonl emission ─────────────────────────────────
|
||||
// events.jsonl emission
|
||||
|
||||
/// Build a tracker whose event sink points at a fresh tempdir, with the
|
||||
/// `events.jsonl` writer for `session` pre-opened (as turn-start would do).
|
||||
|
||||
@@ -111,7 +111,7 @@ const _: () = assert!(
|
||||
|
||||
/// Maps `(CapabilityMode, ToolKind)` -> kept-or-dropped.
|
||||
///
|
||||
/// This `match` is intentionally exhaustive: when `ToolKind` gains a
|
||||
/// This `match` is deliberately exhaustive: when `ToolKind` gains a
|
||||
/// new variant the compiler errors here, forcing a triage decision.
|
||||
pub(crate) fn kind_allowed(mode: CapabilityMode, kind: ToolKind) -> bool {
|
||||
use CapabilityMode as M;
|
||||
@@ -157,9 +157,7 @@ pub(crate) fn kind_allowed(mode: CapabilityMode, kind: ToolKind) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -285,9 +283,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// is_subset_of partial order
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn capability_mode_is_subset_of_reflexive() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Shared transport types used by workspace communication layers.
|
||||
//!
|
||||
//! The `WorkspaceChannel` trait and `MpscChannel` in-process implementation
|
||||
//! have been removed. Sessions now use `WorkspaceHandle` directly (local mode)
|
||||
//! are gone. Sessions now use `WorkspaceHandle` directly (local mode)
|
||||
//! or `ToolHarness` RPC calls (proxy mode). These shared types remain for
|
||||
//! backward compatibility with code that references them.
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ impl AgentSessionConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// WARNING: `tool_config` is intentionally redacted from `Debug` output
|
||||
/// WARNING: `tool_config` is deliberately redacted from `Debug` output
|
||||
/// because `ToolServerConfig.tools[*].params` may contain credentials.
|
||||
impl std::fmt::Debug for AgentSessionConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
|
||||
@@ -22,9 +22,7 @@ pub use kigi_agent::plugins::discovery::DiscoveryConfig as PluginDiscoveryConfig
|
||||
pub use kigi_agent::plugins::trust::TrustStore as PluginTrustStore;
|
||||
pub use kigi_agent::prompt::skills::SkillsConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discover skills visible from the workspace root.
|
||||
///
|
||||
@@ -62,9 +60,7 @@ pub async fn discover_skills(root_cwd: &Path, config: &SkillsConfig) -> Vec<Valu
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AGENTS.md discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discover project-instruction files (AGENTS.md, Claude.md, rules) from the workspace root up to the git root.
|
||||
pub async fn discover_agents_md(root_cwd: &Path) -> Vec<Value> {
|
||||
@@ -101,9 +97,7 @@ pub async fn discover_agents_md(root_cwd: &Path) -> Vec<Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discover plugins visible from the workspace root.
|
||||
///
|
||||
@@ -149,9 +143,7 @@ pub fn discover_plugins(
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load the project config from `<root_cwd>/.kigi/config.toml`.
|
||||
///
|
||||
@@ -199,9 +191,7 @@ fn toml_to_json(v: &toml::Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permissions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load the effective permission configuration for the workspace.
|
||||
///
|
||||
@@ -250,7 +240,7 @@ mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
// ---- Skill discovery tests ----
|
||||
// Skill discovery tests
|
||||
|
||||
// Note: `list_skills` also discovers user-scoped skills from
|
||||
// `~/.kigi/skills/`, so on a developer machine the result may be
|
||||
@@ -328,7 +318,7 @@ mod tests {
|
||||
assert!(found["scope"].is_string(), "scope should be serialized");
|
||||
}
|
||||
|
||||
// ---- AGENTS.md discovery tests ----
|
||||
// AGENTS.md discovery tests
|
||||
|
||||
#[test]
|
||||
fn agent_config_file_wire_matches_workspace_types_mirror() {
|
||||
@@ -412,7 +402,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Plugin discovery tests ----
|
||||
// Plugin discovery tests
|
||||
|
||||
// Note: `discover_plugins` also discovers user-scoped plugins
|
||||
// from `~/.kigi/plugins/`, so tests check for specific plugins.
|
||||
@@ -472,7 +462,7 @@ mod tests {
|
||||
assert!(p["has_skills"].is_boolean());
|
||||
}
|
||||
|
||||
// ---- Project config tests ----
|
||||
// Project config tests
|
||||
|
||||
#[test]
|
||||
fn load_project_config_missing_file_returns_null() {
|
||||
@@ -505,7 +495,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- toml_to_json tests ----
|
||||
// toml_to_json tests
|
||||
|
||||
#[test]
|
||||
fn toml_to_json_basic_types() {
|
||||
@@ -532,7 +522,7 @@ mod tests {
|
||||
assert_eq!(json["items"][2], 3);
|
||||
}
|
||||
|
||||
// ---- Permissions tests ----
|
||||
// Permissions tests
|
||||
|
||||
// Note: `resolve_permissions_with_provenance` checks system-managed
|
||||
// settings and requirements.toml from the global config, so on a
|
||||
|
||||
@@ -98,7 +98,8 @@ fn try_direnv_export(dir: &Path) -> Option<HashMap<String, String>> {
|
||||
if let serde_json::Value::String(s) = v {
|
||||
Some((k, s))
|
||||
} else {
|
||||
None // Skip null values (unset)
|
||||
// Skip null values (unset)
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::capability::CapabilityMode;
|
||||
|
||||
/// Errors surfaced by the workspace public API.
|
||||
///
|
||||
/// `#[non_exhaustive]` so adding new variants is a non-breaking change.
|
||||
/// `#[non_exhaustive]` so introducing new variants is a non-breaking extension.
|
||||
/// Tests should match on variants rather than scrape the `Display` text.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
|
||||
@@ -7,12 +7,10 @@ pub struct AcpSessionFs {
|
||||
root: PathBuf,
|
||||
gateway: GatewaySender,
|
||||
session_id: acp::SessionId,
|
||||
/// When set, any path under `display_cwd` is rewritten to `root` before
|
||||
/// being sent to the extension. This is the defense-in-depth guard for
|
||||
/// AB overlay isolation: if a tool accidentally passes the display path
|
||||
/// (e.g., `/testbed/project/foo.rs`) instead of the overlay path
|
||||
/// (`~/.kigi/worktrees/.../b-overlay/foo.rs`), the adapter rewrites it
|
||||
/// so the extension reads/writes to the correct overlay location.
|
||||
/// Defense-in-depth guard for AB overlay isolation: the model sees
|
||||
/// `display_cwd` (e.g. `/testbed/project`) but the extension must act on
|
||||
/// `root` (`~/.kigi/worktrees/.../b-overlay`), so any path under
|
||||
/// `display_cwd` is rebased onto `root` before the request goes out.
|
||||
display_cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -26,18 +24,11 @@ impl AcpSessionFs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the display CWD for path rewriting.
|
||||
///
|
||||
/// When AB FS isolation is active, the model sees `display_cwd`
|
||||
/// (e.g., `/testbed/project`) but writes should go to `root`
|
||||
/// (the overlay path). Any path under `display_cwd` is rewritten
|
||||
/// to the equivalent path under `root`.
|
||||
pub fn with_display_cwd(mut self, display_cwd: PathBuf) -> Self {
|
||||
self.display_cwd = Some(display_cwd);
|
||||
self
|
||||
}
|
||||
|
||||
/// Rewrite a display path to the overlay path if needed.
|
||||
fn resolve_path(&self, path: &Path) -> PathBuf {
|
||||
if let Some(ref display) = self.display_cwd
|
||||
&& let Ok(suffix) = path.strip_prefix(display)
|
||||
@@ -106,8 +97,7 @@ impl AsyncFileSystem for AcpSessionFs {
|
||||
}
|
||||
|
||||
async fn delete_file(&self, path: &Path) -> Result<(), FsError> {
|
||||
// ACP protocol doesn't support file deletion yet
|
||||
// For now, we'll log a warning and return Ok (no-op)
|
||||
// ACP has no deletion request, so the best we can do is surface it loudly.
|
||||
tracing::warn!(?path, "ACP filesystem does not support file deletion");
|
||||
Err(FsError::Other(
|
||||
"File deletion not supported via ACP".to_string(),
|
||||
@@ -119,12 +109,11 @@ impl AsyncFileSystem for AcpSessionFs {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// resolve_path only uses self.root and self.display_cwd — extract
|
||||
// the logic into a standalone test helper that doesn't need a gateway.
|
||||
// resolve_path only reads root and display_cwd, but constructing an
|
||||
// AcpSessionFs needs a live gateway; this mirrors its logic instead.
|
||||
fn test_resolve(root: &str, display_cwd: Option<&str>, input: &str) -> PathBuf {
|
||||
let root = PathBuf::from(root);
|
||||
let display = display_cwd.map(PathBuf::from);
|
||||
// Inline the same logic as resolve_path
|
||||
if let Some(ref display) = display
|
||||
&& let Ok(suffix) = Path::new(input).strip_prefix(display)
|
||||
{
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
//! AcpFsAdapter: implements `kigi-tools::AsyncFileSystem` using ACP gateway calls.
|
||||
//!
|
||||
//! This adapter enables file tool execution over ACP (remote filesystem).
|
||||
//! It translates kigi-tools' `AsyncFileSystem` trait into ACP protocol calls:
|
||||
//! `read_file()` → read_text_file
|
||||
//! `write_file()` → write_text_file
|
||||
//! `delete_file()` → not supported by ACP (returns error)
|
||||
//!
|
||||
//! Mirrors the pattern of `AcpTerminalAdapter` for terminal execution.
|
||||
|
||||
use std::path::Path;
|
||||
@@ -14,11 +6,9 @@ use agent_client_protocol as acp;
|
||||
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
use kigi_tools::computer::types::{AsyncFileSystem, ComputerError};
|
||||
|
||||
/// Wraps kigi-shell's ACP gateway to satisfy kigi-tools' AsyncFileSystem.
|
||||
///
|
||||
/// When a client advertises `clientCapabilities.fs.readTextFile` and `writeTextFile`,
|
||||
/// file operations from tools (read_file, search_replace, etc.) are routed through
|
||||
/// the ACP gateway back to the client instead of hitting the local disk directly.
|
||||
/// Used when a client advertises `clientCapabilities.fs.readTextFile` and
|
||||
/// `writeTextFile`: tool file operations are then routed back to the client over
|
||||
/// the gateway instead of hitting the local disk.
|
||||
pub struct AcpFsAdapter {
|
||||
gateway: GatewaySender,
|
||||
session_id: acp::SessionId,
|
||||
@@ -63,7 +53,7 @@ impl AsyncFileSystem for AcpFsAdapter {
|
||||
}
|
||||
|
||||
async fn delete_file(&self, path: &Path) -> Result<(), ComputerError> {
|
||||
// ACP protocol doesn't support file deletion yet
|
||||
// ACP has no deletion request, so the best we can do is surface it loudly.
|
||||
tracing::warn!(?path, "ACP filesystem does not support file deletion");
|
||||
Err(ComputerError::io("File deletion not supported via ACP"))
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ async fn render_text_resource(
|
||||
/// Render a diff citation as `<diff_contents>`.
|
||||
///
|
||||
/// The text is passed through verbatim (no line-number rewriting) since it
|
||||
/// represents a change, not a file snapshot.
|
||||
/// represents a delta, not a file snapshot.
|
||||
async fn render_diff_resource(
|
||||
text_resource: &agent_client_protocol::TextResourceContents,
|
||||
) -> Option<String> {
|
||||
|
||||
@@ -120,16 +120,13 @@ mod tests {
|
||||
assert!(cache_path.to_string_lossy().ends_with("goto_index.bin"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lazy-start mechanic tests
|
||||
//
|
||||
// These tests verify the core lazy-start behavior:
|
||||
// `CodebaseIndexManager::get()` returns None before the index is created,
|
||||
// which maps to `kigi/code/status` reporting `reason: notStarted`.
|
||||
// `get_or_create()` is the lazy-start entry point called by
|
||||
// `MvpAgent::start_codebase_index_for_code_nav` on the first code-nav
|
||||
// request for an eligible session.
|
||||
// =========================================================================
|
||||
|
||||
/// An empty CodebaseIndexManager returns None for any path.
|
||||
///
|
||||
|
||||
@@ -21,7 +21,6 @@ pub struct ContentSearchParams {
|
||||
pub respect_gitignore: bool,
|
||||
}
|
||||
|
||||
/// Batch of results sent during streaming search.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ContentSearchBatch {
|
||||
pub files: Vec<ContentMatchFile>,
|
||||
@@ -127,8 +126,9 @@ fn parse_file_path_from_json(root: &Path, json: &serde_json::Value) -> Option<St
|
||||
Some(root.join(normalized).to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Streaming content search with batched status notifications.
|
||||
/// Set `cancel` to true to abort the search early.
|
||||
/// `on_status` fires at most every [`BATCH_INTERVAL_MS`] with the files matched
|
||||
/// since the last call, then once more with `done`. Set `cancel` to true to
|
||||
/// abort the search early.
|
||||
pub async fn content_search_streaming<F>(
|
||||
root: &Path,
|
||||
params: &ContentSearchParams,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Filesystem extension ops (`workspace.fs_*`) — the server-proxied backing
|
||||
//! for the shell's `kigi/fs/*` ACP extension methods.
|
||||
//!
|
||||
//! These mirror the pure functions that previously lived only in the
|
||||
//! These mirror the pure functions that earlier lived only in the
|
||||
//! shell (`kigi-shell/src/session/file_system.rs`) so that, in proxy
|
||||
//! mode, a `kigi/fs/*` request executes on the *remote* workspace server
|
||||
//! instead of the agent host. Each request type implements
|
||||
@@ -165,9 +165,7 @@ impl WorkspaceOp for FsDeleteFileReq {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Pure helpers — ported verbatim from the shell so output is identical.
|
||||
// =========================================================================
|
||||
|
||||
fn list(
|
||||
abs_path: &Path,
|
||||
@@ -258,9 +256,7 @@ fn build_file_entry(bytes: &[u8]) -> FsReadFileData {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests for the pure helpers (no `WorkspaceHandle` required).
|
||||
// =========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -103,7 +103,8 @@ fn collect_all_contents(
|
||||
max_depth: usize,
|
||||
max_dirs: usize,
|
||||
) -> Result<HashMap<PathBuf, DirContents>, FsError> {
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
|
||||
let contents_map: DashMap<PathBuf, DirContents> = DashMap::new();
|
||||
let files_count = std::sync::atomic::AtomicUsize::new(0);
|
||||
@@ -120,7 +121,8 @@ fn collect_all_contents(
|
||||
);
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
.max_depth(Some(max_depth + 1)) // +1 because depth 0 is root itself
|
||||
// +1 because depth 0 is root itself
|
||||
.max_depth(Some(max_depth + 1))
|
||||
.follow_links(false)
|
||||
.same_file_system(true)
|
||||
.ignore(true)
|
||||
@@ -140,7 +142,8 @@ fn collect_all_contents(
|
||||
);
|
||||
|
||||
{
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
walker.run(|| {
|
||||
let contents_map = &contents_map;
|
||||
let files_count = &files_count;
|
||||
@@ -209,16 +212,18 @@ fn collect_all_contents(
|
||||
}
|
||||
|
||||
let _entries_total = entries_visited.load(std::sync::atomic::Ordering::Relaxed);
|
||||
// (instrumentation_timer.with_field calls removed — dev-only, not part of Phase 1 move)
|
||||
// (instrumentation_timer.with_field calls omitted — dev-only, not part of Phase 1 move)
|
||||
|
||||
let mut contents_map: HashMap<PathBuf, DirContents> = {
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
contents_map.into_iter().collect()
|
||||
};
|
||||
|
||||
// Sort all entries for stable output
|
||||
{
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
for contents in contents_map.values_mut() {
|
||||
contents.files.sort_by_cached_key(|n| n.to_lowercase());
|
||||
contents.dirs.sort_by_cached_key(|n| n.to_lowercase());
|
||||
@@ -342,7 +347,8 @@ pub async fn list_contents(
|
||||
path: impl Into<PathBuf>,
|
||||
limits: ListContentsLimits,
|
||||
) -> Result<String, FsError> {
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
let t_total = Instant::now();
|
||||
let path: PathBuf = path.into();
|
||||
|
||||
@@ -363,7 +369,8 @@ pub async fn list_contents(
|
||||
let max_depth = limits.max_depth;
|
||||
let max_dirs = limits.max_dirs_visited;
|
||||
let contents = {
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
tokio::task::spawn_blocking(move || collect_all_contents(&path_clone, max_depth, max_dirs))
|
||||
.await
|
||||
.map_err(|e| FsError::Other(format!("walk join error: {e}")))?
|
||||
@@ -380,7 +387,8 @@ pub async fn list_contents(
|
||||
depth_limit_hit,
|
||||
dirs_limit_hit,
|
||||
) = {
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
|
||||
let mut root_node = DirectoryNode::new(path, 0, &contents);
|
||||
|
||||
@@ -394,7 +402,8 @@ pub async fn list_contents(
|
||||
|
||||
let mut remaining_chars = lim_characters - min_chars;
|
||||
let mut to_fit_files = true;
|
||||
let mut dirs_visited: usize = 1; // Count root as visited
|
||||
// Count root as visited
|
||||
let mut dirs_visited: usize = 1;
|
||||
let mut max_depth_reached: usize = 0;
|
||||
let mut depth_limit_hit = false;
|
||||
let mut dirs_limit_hit = false;
|
||||
@@ -448,7 +457,8 @@ pub async fn list_contents(
|
||||
};
|
||||
|
||||
{
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
let mut q: VecDeque<&mut DirectoryNode> = VecDeque::new();
|
||||
if to_fit_files {
|
||||
q.push_back(&mut root_node);
|
||||
@@ -481,7 +491,8 @@ pub async fn list_contents(
|
||||
}
|
||||
|
||||
let output = {
|
||||
let _timer = (); // instrumentation_timer noop (dev infra)
|
||||
// instrumentation_timer noop (dev infra)
|
||||
let _timer = ();
|
||||
let mut output = format!("{path_head}\n");
|
||||
if root_node.is_expanded() {
|
||||
output.push_str(&root_node.get_complete_str());
|
||||
|
||||
@@ -46,9 +46,7 @@ pub fn bytes_to_string(file_bytes: Vec<u8>) -> Result<String, FsError> {
|
||||
String::from_utf8(file_bytes).map_err(|e| FsError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AsyncFsWrapper - Generic wrapper that accepts any path type
|
||||
// ============================================================================
|
||||
|
||||
/// A wrapper around `AsyncFileSystem` that accepts any path type implementing `ToAbsPath`.
|
||||
///
|
||||
@@ -129,14 +127,12 @@ impl AsyncFsWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write data to a file.
|
||||
pub async fn write_file<P: ToAbsPath>(&self, path: P, data: &[u8]) -> Result<(), FsError> {
|
||||
self.inner
|
||||
.write_file(&path.to_abs_path(self.root()), data)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a file.
|
||||
pub async fn delete_file<P: ToAbsPath>(&self, path: P) -> Result<(), FsError> {
|
||||
self.inner.delete_file(&path.to_abs_path(self.root())).await
|
||||
}
|
||||
|
||||
@@ -265,7 +265,8 @@ impl FuzzyFileMatcher {
|
||||
.filter(|e| !self.dirs || e.is_dir)
|
||||
.take(k)
|
||||
.cloned()
|
||||
.collect(); // should be already sorted
|
||||
// should be already sorted
|
||||
.collect();
|
||||
}
|
||||
|
||||
// https://github.com/helix-editor/helix/blob/d79cce4e4bfc24dd204f1b294c899ed73f7e9453/helix-term/src/ui/completion.rs#L369
|
||||
@@ -280,8 +281,7 @@ impl FuzzyFileMatcher {
|
||||
|
||||
while items.len() < k
|
||||
&& let Some(m) = iter.next()
|
||||
// for empty queries, return everything; otherwise, apply heuristic min-score limit
|
||||
&& (self.query.is_empty() || m.score >= min_score)
|
||||
&& (self.query.is_empty() || m.score >= min_score)
|
||||
{
|
||||
fn extract_match(
|
||||
m: &Match,
|
||||
|
||||
@@ -109,13 +109,15 @@ pub async fn git_status_short(working_directory: impl Into<PathBuf>) -> Result<S
|
||||
}
|
||||
|
||||
fn git_status_impl(working_directory: &Path) -> Result<String, FsError> {
|
||||
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.impl")
|
||||
// dev macro; noop stub ("git_status.impl")
|
||||
let _timer = /* instrumentation_timer */ () ;
|
||||
let max_status_chars = 1000;
|
||||
let mut output = String::with_capacity(max_status_chars);
|
||||
|
||||
// Get branch name
|
||||
let branch_name = {
|
||||
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.branch_info")
|
||||
// dev macro; noop stub ("git_status.branch_info")
|
||||
let _timer = /* instrumentation_timer */ () ;
|
||||
run_git(working_directory, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
};
|
||||
|
||||
@@ -136,7 +138,8 @@ fn git_status_impl(working_directory: &Path) -> Result<String, FsError> {
|
||||
|
||||
// Get upstream ahead/behind
|
||||
{
|
||||
let _timer = (); // instrumentation_timer noop stub
|
||||
// instrumentation_timer noop stub
|
||||
let _timer = ();
|
||||
if let Some(upstream_name) = run_git(
|
||||
working_directory,
|
||||
&["rev-parse", "--abbrev-ref", "@{upstream}"],
|
||||
@@ -177,7 +180,8 @@ fn git_status_impl(working_directory: &Path) -> Result<String, FsError> {
|
||||
|
||||
// Get staged changes (index vs HEAD) — fast, no workdir scan
|
||||
let staged_output = {
|
||||
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.staged")
|
||||
// dev macro; noop stub ("git_status.staged")
|
||||
let _timer = /* instrumentation_timer */ () ;
|
||||
run_git(
|
||||
working_directory,
|
||||
&["diff", "--cached", "--name-status", "HEAD"],
|
||||
|
||||
@@ -148,12 +148,11 @@ type FxBuildHasher = std::hash::BuildHasherDefault<FxHasher>;
|
||||
/// Type alias for HashMap with FxHash (fast, non-cryptographic).
|
||||
type FxHashMap<K, V> = HashMap<K, V, FxBuildHasher>;
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const MAGIC_INDEX: &[u8; 4] = b"FIDX";
|
||||
#[allow(dead_code)] // Reserved for delta wire format
|
||||
// Reserved for delta wire format
|
||||
#[allow(dead_code)]
|
||||
const MAGIC_DELTA: &[u8; 4] = b"FDLT";
|
||||
const VERSION: u16 = 1;
|
||||
|
||||
@@ -161,7 +160,8 @@ const FLAG_COMPRESSED: u16 = 0x0001;
|
||||
|
||||
const ENTRY_FLAG_IS_DIR: u8 = 0x01;
|
||||
|
||||
#[allow(dead_code)] // Documentation constant
|
||||
// Documentation constant
|
||||
#[allow(dead_code)]
|
||||
const MAX_DEPTH: usize = 255;
|
||||
|
||||
/// Number of threads to use for parallel directory walking.
|
||||
@@ -169,12 +169,11 @@ fn num_cpus() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
.min(8) // Cap at 8 to avoid excessive parallelism
|
||||
// Cap at 8 to avoid excessive parallelism
|
||||
.min(8)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WalkOptions - configuration for directory walking
|
||||
// ============================================================================
|
||||
|
||||
/// Options for building a FileIndex from a directory walk.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -234,9 +233,7 @@ impl WalkOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SegmentId - a handle into the string interner
|
||||
// ============================================================================
|
||||
|
||||
/// A compact identifier for an interned path segment.
|
||||
/// Using u32 allows up to 4 billion unique segments (plenty).
|
||||
@@ -253,9 +250,7 @@ impl SegmentId {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StringInterner - deduplicates path segments
|
||||
// ============================================================================
|
||||
|
||||
/// Arena-based string interner for path segments.
|
||||
///
|
||||
@@ -435,7 +430,6 @@ impl StringInterner {
|
||||
self.offsets.is_empty()
|
||||
}
|
||||
|
||||
/// Total bytes used by the arena.
|
||||
pub fn arena_bytes(&self) -> usize {
|
||||
self.arena.len()
|
||||
}
|
||||
@@ -474,9 +468,7 @@ impl StringInterner {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FileEntry - a single file/directory in the index
|
||||
// ============================================================================
|
||||
|
||||
/// Inline storage for short paths (covers 99% of cases).
|
||||
/// Paths deeper than 6 segments will heap-allocate.
|
||||
@@ -519,9 +511,7 @@ impl FileEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PathKey - for O(1) lookup by path
|
||||
// ============================================================================
|
||||
|
||||
/// A key for looking up entries by path.
|
||||
/// Uses the segment IDs directly for fast comparison.
|
||||
@@ -534,9 +524,7 @@ impl From<&[SegmentId]> for PathKey {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FileIndex - the main index structure
|
||||
// ============================================================================
|
||||
|
||||
/// A compact, serializable file index.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -547,7 +535,7 @@ pub struct FileIndex {
|
||||
entries: Vec<FileEntry>,
|
||||
/// Path to entry index lookup (for O(1) removal)
|
||||
path_to_idx: FxHashMap<PathKey, usize>,
|
||||
/// Tracks removed indices for potential compaction
|
||||
/// Tracks dropped indices for potential compaction
|
||||
removed_count: usize,
|
||||
}
|
||||
|
||||
@@ -683,14 +671,14 @@ impl FileIndex {
|
||||
self.path_to_idx.insert(key, idx);
|
||||
}
|
||||
|
||||
/// Remove a path from the index.
|
||||
/// Returns true if the path was found and removed.
|
||||
/// Drop a path from the index.
|
||||
/// Returns true if the path was found and cleared.
|
||||
pub fn remove(&mut self, path: impl AsRef<Path>) -> bool {
|
||||
let segments = self.intern_path(path.as_ref());
|
||||
let key = PathKey::from(segments.as_slice());
|
||||
|
||||
if let Some(idx) = self.path_to_idx.remove(&key) {
|
||||
// Mark as removed by clearing segments (tombstone)
|
||||
// Mark as cleared by clearing segments (tombstone)
|
||||
self.entries[idx].segments.clear();
|
||||
self.removed_count += 1;
|
||||
|
||||
@@ -787,9 +775,7 @@ impl FileIndex {
|
||||
self.interner.get(id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
fn intern_path(&mut self, path: &Path) -> smallvec::SmallVec<[SegmentId; INLINE_SEGMENTS]> {
|
||||
path.components()
|
||||
@@ -829,9 +815,7 @@ impl FileIndex {
|
||||
self.removed_count = 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Serialization
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// Serialize to binary format.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
@@ -865,7 +849,8 @@ impl FileIndex {
|
||||
// Header
|
||||
w.write_all(MAGIC_INDEX)?;
|
||||
w.write_all(&VERSION.to_le_bytes())?;
|
||||
w.write_all(&0u16.to_le_bytes())?; // flags (no compression)
|
||||
// flags (no compression)
|
||||
w.write_all(&0u16.to_le_bytes())?;
|
||||
w.write_all(&(self.interner.len() as u32).to_le_bytes())?;
|
||||
w.write_all(&(self.len() as u32).to_le_bytes())?;
|
||||
|
||||
@@ -873,7 +858,8 @@ impl FileIndex {
|
||||
for (_, seg) in self.interner.iter() {
|
||||
let len = seg.len() as u16;
|
||||
w.write_all(&len.to_le_bytes())?;
|
||||
w.write_all(seg)?; // seg is &BStr which derefs to &[u8]
|
||||
// seg is &BStr which derefs to &[u8]
|
||||
w.write_all(seg)?;
|
||||
}
|
||||
|
||||
// Entry table
|
||||
@@ -997,33 +983,31 @@ impl FileIndex {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FileIndexDelta - incremental updates
|
||||
// ============================================================================
|
||||
|
||||
/// An incremental update to the file index.
|
||||
/// An incremental delta to the file index.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FileIndexDelta {
|
||||
/// Add new entries
|
||||
/// Append new entries
|
||||
Add(Vec<(String, bool)>),
|
||||
/// Remove entries by path
|
||||
/// Drop entries by path
|
||||
Remove(Vec<String>),
|
||||
/// Multiple operations batched
|
||||
Batch(Vec<FileIndexDelta>),
|
||||
}
|
||||
|
||||
impl FileIndexDelta {
|
||||
/// Create an add delta.
|
||||
/// Create an insert delta.
|
||||
pub fn add(entries: Vec<(String, bool)>) -> Self {
|
||||
Self::Add(entries)
|
||||
}
|
||||
|
||||
/// Create a remove delta.
|
||||
/// Create a delete delta.
|
||||
pub fn remove(paths: Vec<String>) -> Self {
|
||||
Self::Remove(paths)
|
||||
}
|
||||
|
||||
/// Check if the delta is empty (no actual changes).
|
||||
/// Check if the delta is empty (no real delta).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::Add(entries) => entries.is_empty(),
|
||||
@@ -1113,9 +1097,7 @@ impl FileIndexDelta {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1127,7 +1109,7 @@ mod tests {
|
||||
|
||||
let id1 = interner.intern("src");
|
||||
let id2 = interner.intern("lib");
|
||||
let id3 = interner.intern("src"); // duplicate
|
||||
let id3 = interner.intern("src");
|
||||
|
||||
assert_eq!(id1, id3);
|
||||
assert_ne!(id1, id2);
|
||||
@@ -1151,7 +1133,8 @@ mod tests {
|
||||
assert!(!index.contains("src/foo.rs"));
|
||||
|
||||
// Check interning worked
|
||||
assert!(index.num_segments() < 6); // "src" should be shared
|
||||
// "src" should be shared
|
||||
assert!(index.num_segments() < 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1164,7 +1147,8 @@ mod tests {
|
||||
assert_eq!(index.len(), 2);
|
||||
|
||||
assert!(index.remove("src/main.rs"));
|
||||
assert!(!index.remove("src/main.rs")); // already removed
|
||||
// already cleared
|
||||
assert!(!index.remove("src/main.rs"));
|
||||
|
||||
assert_eq!(index.len(), 1);
|
||||
assert!(!index.contains("src/main.rs"));
|
||||
@@ -1379,16 +1363,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_delta_is_empty() {
|
||||
// Empty add
|
||||
// Empty insert
|
||||
assert!(FileIndexDelta::Add(vec![]).is_empty());
|
||||
|
||||
// Non-empty add
|
||||
// Non-Empty insert
|
||||
assert!(!FileIndexDelta::Add(vec![("src/main.rs".to_string(), false)]).is_empty());
|
||||
|
||||
// Empty remove
|
||||
// Empty delete
|
||||
assert!(FileIndexDelta::Remove(vec![]).is_empty());
|
||||
|
||||
// Non-empty remove
|
||||
// Non-Empty delete
|
||||
assert!(!FileIndexDelta::Remove(vec!["src/main.rs".to_string()]).is_empty());
|
||||
|
||||
// Empty batch
|
||||
@@ -1464,7 +1448,8 @@ mod tests {
|
||||
let segment = format!("segment_{}", i);
|
||||
assert_eq!(interner.intern(&segment), id);
|
||||
}
|
||||
assert_eq!(interner.len(), segment_count); // No new segments added
|
||||
// No new segments appended
|
||||
assert_eq!(interner.len(), segment_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1503,7 +1488,7 @@ mod tests {
|
||||
assert!(index.contains("lib/core/main.rs"));
|
||||
assert!(!index.contains("nonexistent/path/file.rs"));
|
||||
|
||||
// Removal should work correctly
|
||||
// Drop should work correctly
|
||||
assert!(index.remove("src/utils/mod.rs"));
|
||||
assert!(!index.contains("src/utils/mod.rs"));
|
||||
assert_eq!(index.len(), 154);
|
||||
@@ -1525,7 +1510,7 @@ mod tests {
|
||||
assert!(!index.contains("foo/bar/baz.rs"));
|
||||
assert!(!index.contains("nonexistent.txt"));
|
||||
|
||||
// Segment count should be unchanged
|
||||
// Segment count should be `unchanged`
|
||||
assert_eq!(index.num_segments(), initial_segments);
|
||||
}
|
||||
|
||||
@@ -1540,7 +1525,8 @@ mod tests {
|
||||
assert_eq!(interner.get_bytes(id_valid), Some(b"hello".as_slice()));
|
||||
|
||||
// Intern raw bytes that are not valid UTF-8
|
||||
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82]; // Invalid UTF-8 sequence
|
||||
// Invalid UTF-8 sequence
|
||||
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82];
|
||||
let id_invalid = interner.intern_bytes(invalid_utf8);
|
||||
|
||||
// get() should return None for non-UTF-8
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::process::Command;
|
||||
|
||||
use crate::file_system::FsError;
|
||||
|
||||
/// jj template: change ID, commit ID, description, bookmarks.
|
||||
const JJ_LOG_TEMPLATE: &str = r#"separate("\n",
|
||||
"Change: " ++ change_id.shortest(8),
|
||||
"Commit: " ++ commit_id.shortest(8),
|
||||
@@ -18,7 +17,7 @@ const JJ_LOG_TEMPLATE: &str = r#"separate("\n",
|
||||
"")
|
||||
)"#;
|
||||
|
||||
/// Compact jj status for the system prompt (~1k chars max).
|
||||
/// Truncated to roughly 1k chars so the prompt budget stays bounded.
|
||||
pub async fn jj_status(working_directory: impl Into<PathBuf>) -> Result<String, FsError> {
|
||||
let working_directory = working_directory.into();
|
||||
tokio::task::spawn_blocking(move || jj_status_impl(&working_directory))
|
||||
@@ -64,7 +63,8 @@ fn jj_status_impl(cwd: &Path) -> Result<String, FsError> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Run a jj command synchronously, returning trimmed stdout or `None` on failure.
|
||||
/// `--ignore-working-copy` keeps this read-only: without it jj snapshots the
|
||||
/// working copy into a new commit as a side effect of the query.
|
||||
fn run_jj(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let mut cmd = Command::new("jj");
|
||||
cmd.arg("--ignore-working-copy")
|
||||
|
||||
@@ -67,7 +67,6 @@ use std::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
// Canonical in kigi-workspace-types; re-exported for existing paths.
|
||||
pub use kigi_workspace_types::rpc::search::{ClientId, ContentSearchRequest, TargetClientId};
|
||||
|
||||
impl From<ContentSearchRequest> for ContentSearchParams {
|
||||
@@ -164,13 +163,12 @@ pub struct FuzzySearchContext {
|
||||
pub min_generation: usize,
|
||||
pub has_query: bool,
|
||||
pub query_version: usize,
|
||||
/// The root path for this search (used to convert relative paths to absolute).
|
||||
/// Daemon results are relative to this; callers absolutize against it.
|
||||
pub root: PathBuf,
|
||||
/// Session ID for routing notifications.
|
||||
/// Used by the relay to route notifications to session subscribers.
|
||||
/// Routes notifications to the session's subscribers.
|
||||
pub session_id: Option<String>,
|
||||
/// Target client ID for routing notifications.
|
||||
/// Extracted from `_meta.clientId` in the open request.
|
||||
/// Taken from `_meta.clientId` on the open request; routes notifications
|
||||
/// through the relay to that one client.
|
||||
pub target_client_id: TargetClientId,
|
||||
}
|
||||
|
||||
@@ -233,16 +231,12 @@ impl FuzzySearchManager {
|
||||
search_id
|
||||
}
|
||||
|
||||
/// Get the session ID for a search, if one was set.
|
||||
/// Used for routing notifications to session subscribers.
|
||||
pub fn get_session_id(&self, search_id: &str) -> Option<String> {
|
||||
self.searches
|
||||
.get(search_id)
|
||||
.and_then(|ctx| ctx.session_id.clone())
|
||||
}
|
||||
|
||||
/// Get the target client ID for a search, if one was set.
|
||||
/// Used for routing notifications to the correct client via relay.
|
||||
pub fn get_target_client_id(&self, search_id: &str) -> TargetClientId {
|
||||
self.searches
|
||||
.get(search_id)
|
||||
@@ -250,8 +244,6 @@ impl FuzzySearchManager {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get the root path for a search.
|
||||
/// Used to convert relative paths to absolute paths in results.
|
||||
pub fn get_root(&self, search_id: &str) -> Option<PathBuf> {
|
||||
self.searches.get(search_id).map(|ctx| ctx.root.clone())
|
||||
}
|
||||
|
||||
@@ -158,9 +158,7 @@ pub(super) fn build_glob_overrides(
|
||||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Paginated listing
|
||||
// =========================================================================
|
||||
|
||||
/// One listed node in neutral form (no wire serialization). Consumers map
|
||||
/// this to their own node shape.
|
||||
@@ -265,9 +263,7 @@ pub fn list_directory_paged(abs_dir: &Path, opts: ListOptions<'_>, max_collect:
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Ranged, binary-safe reads
|
||||
// =========================================================================
|
||||
|
||||
/// A read chunk in the requested transfer encoding.
|
||||
pub enum ChunkPayload {
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn decide_inputs_with_interactive(
|
||||
// (its own git2 discover), and `repo_configs_present` → `RepoDirChain::resolve`
|
||||
// discovers the same repo again. Collapsing the two would mean threading the
|
||||
// resolved root into key derivation (rippling `workspace_key` repo-wide) — out
|
||||
// of scope; NOT the redundant discovers this change already removed.
|
||||
// of scope; NOT the redundant discovers the shared dir-chain already removed.
|
||||
repo_configs_present: repo_configs_present(cwd),
|
||||
is_interactive,
|
||||
// An over-broad key (home / fs-root / non-absolute) can never be recorded
|
||||
@@ -260,7 +260,7 @@ pub fn repo_config_kinds(cwd: &Path) -> Vec<&'static str> {
|
||||
fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str> {
|
||||
// Resolve the git root + cwd→root dir chain ONCE and reuse it across the
|
||||
// git2-based marker checks below: this gate does 1 git2 discover + 1 git2
|
||||
// walk (+ the settings-compat path's own cheap `.git`-existence walk, intentionally separate —
|
||||
// walk (+ the settings-compat path's own cheap `.git`-existence walk, deliberately separate —
|
||||
// see its check). Each walker used to run its own git2 discover + walk (~5
|
||||
// discovers), and on a non-git dir each discover walks to the filesystem root
|
||||
// — wasteful anywhere, and Windows taxes every such syscall 10-100x.
|
||||
@@ -680,7 +680,7 @@ mod tests {
|
||||
fn repo_config_kinds_matches_gate_and_reports_all_kinds() {
|
||||
// SSOT guard: `repo_config_kinds` (full scan) must agree with the gate
|
||||
// (`repo_configs_present == !repo_config_kinds(..).is_empty()`) AND report
|
||||
// the kinds the single-source refactor added — `plugins` via
|
||||
// the kinds the single-source scan reports — `plugins` via
|
||||
// `[plugins].paths`, `claude` via `.claude/settings.json`, `agents` via
|
||||
// `.kigi/agents` — even when launched from a SUBDIR (the cwd→git-root walk
|
||||
// that `first_only` shares). Guards against silent drift between the two.
|
||||
@@ -841,7 +841,8 @@ mod tests {
|
||||
let _home = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
|
||||
let _unset = EnvVarGuard::unset(kigi_version::TEST_VERSION_ENV);
|
||||
if option_env!("KIGI_VERSION").is_some() {
|
||||
return; // a release-stamped test binary is not a local build
|
||||
// a release-stamped test binary is not a local build
|
||||
return;
|
||||
}
|
||||
let tmp = repo_tmp();
|
||||
let key = workspace_key(tmp.path());
|
||||
@@ -880,7 +881,7 @@ mod tests {
|
||||
#[test]
|
||||
fn revoke_folder_trust_store_persists_untrust_for_trusted_folder() {
|
||||
// The store half of revoke, tested directly (not just via the shell
|
||||
// wrapper): a previously-trusted folder reports was_trusted=true AND gets
|
||||
// wrapper): an already-trusted folder reports was_trusted=true AND gets
|
||||
// an explicit `set_untrusted` persisted, so it is untrusted on reload.
|
||||
// KIGI_SHARE_DIR-isolated so the seed/deny hit a temp store, not the real file.
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -259,7 +259,7 @@ fn open_sqlite_transaction_with_journal_mode(
|
||||
// non-symlink final file are validated above; adversarial swap-and-restore
|
||||
// races are outside this scanner's local-user threat model. Only local WAL
|
||||
// reaches this direct read-only/query-only open; its native coordination may
|
||||
// still update SHM read marks despite scanner SQL making no logical writes.
|
||||
// still touch SHM read marks despite scanner SQL making no logical writes.
|
||||
let connection = Connection::open_with_flags(
|
||||
&opened.path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY
|
||||
|
||||
@@ -485,6 +485,8 @@ fn is_generated_prompt(value: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
let value = value.trim_start();
|
||||
// Tool-injected wrappers open with a lowercase tag (`<command-message>`,
|
||||
// `<local-command-stdout>`); prose a user types rarely does.
|
||||
value
|
||||
.strip_prefix('<')
|
||||
.and_then(|rest| rest.chars().next())
|
||||
|
||||
@@ -9,6 +9,8 @@ pub(super) fn scoped_project_dirs(config_dir: &Path, cwd: &Path) -> Vec<PathBuf>
|
||||
if let Ok(repository) = git2::Repository::discover(cwd) {
|
||||
if let Some(workdir) = repository.workdir() {
|
||||
paths.push(dunce::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()));
|
||||
// A linked worktree has its own gitdir distinct from the shared
|
||||
// commondir, whose parent is the main worktree.
|
||||
if repository.path() != repository.commondir()
|
||||
&& let Some(main_workdir) = repository.commondir().parent()
|
||||
{
|
||||
|
||||
@@ -78,6 +78,8 @@ fn highest_named_state_database(root: &ApprovedRoot) -> Option<PathBuf> {
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(_) => Some(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
// An unreadable entry still claims its generation, so the caller
|
||||
// falls back to rollout files instead of trusting an older database.
|
||||
Err(_) => Some(path),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Bounded, metadata-only listing of foreign coding-agent sessions.
|
||||
//! Foreign SQLite stores are opened only when `kigi_sqlite_journal::JournalMode`
|
||||
//! selects local WAL. The direct read-only/query-only transaction makes no
|
||||
//! logical writes, though WAL coordination may update shared-memory read marks.
|
||||
//! logical writes, though WAL coordination may touch shared-memory read marks.
|
||||
//! Network filesystems fail soft before SQLite open, conversion, or writes.
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![allow(dead_code)] // Functions consumed by handle.rs event forwarder wiring
|
||||
// Functions consumed by handle.rs event forwarder wiring
|
||||
#![allow(dead_code)]
|
||||
//! FsNotify adapter functions bridging [`kigi_fsnotify`] events to
|
||||
//! workspace subsystems (hunk tracker, codebase graph, workspace
|
||||
//! event broadcast).
|
||||
|
||||
@@ -58,8 +58,7 @@ pub fn init_metrics() {
|
||||
#[cfg(test)]
|
||||
pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
/// Crate-shared RAII guard for a single process env var in tests: sets (or
|
||||
/// unsets) it on construction and restores the prior value on drop. The ONE
|
||||
/// generic env-var guard for the whole crate (replaces the per-module copies).
|
||||
/// unsets) it on construction and restores the prior value on drop.
|
||||
///
|
||||
/// Hold it together with [`ENV_TEST_LOCK`] for the test's lifetime, acquiring
|
||||
/// the lock FIRST so it drops LAST — the env restore (this guard) then runs
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Port of common agent auto-permission classifier semantics adapted to Kigi's
|
||||
//! `AccessKind` permission gate (classifier blocks prompt the user; upstream
|
||||
//! denial-limit tracking is intentionally not ported).
|
||||
//! denial-limit tracking is deliberately not ported).
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
@@ -115,7 +115,7 @@ impl ClassifierContext {
|
||||
/// Flat transcript text feeding the heuristic substring pre-check. Renders all
|
||||
/// turns including assistant tool_use args (`{tool} {args}`), so the
|
||||
/// dangerous-pattern / hostile-intent blob now also scans tool-call args — a
|
||||
/// conservative broadening (only adds matches), not a strict-parity claim.
|
||||
/// conservative broadening (only expands matches), not a strict-parity claim.
|
||||
fn transcript_text(&self) -> String {
|
||||
self.turns
|
||||
.iter()
|
||||
@@ -368,7 +368,8 @@ const ROUTINE_PREFIXES: &[&str] = &[
|
||||
"kubectl get",
|
||||
"kubectl logs",
|
||||
"kubectl describe",
|
||||
"set", // shell options affect only the spawned shell
|
||||
// shell options affect only the spawned shell
|
||||
"set",
|
||||
];
|
||||
|
||||
/// Env var KEYs safe to set for a routine command: cosmetic / logging only, with
|
||||
@@ -405,7 +406,7 @@ fn classify_bash(cmd: &str) -> ClassifierVerdict {
|
||||
return ClassifierVerdict::Block;
|
||||
};
|
||||
// Default-deny env: an assigned env KEY outside the cosmetic-safe allowlist
|
||||
// (or any `env` option) can change which binary runs / how code resolves.
|
||||
// (or any `env` option) can differ which binary runs / how code resolves.
|
||||
// Read from the PARSED, quote-stripped tree so `env "LD_PRELOAD=..."` can't
|
||||
// hide the key.
|
||||
if sets_unsafe_env(tree.root_node(), cmd, &cmds) {
|
||||
@@ -771,10 +772,12 @@ fn command_env_is_unsafe(words: &[String]) -> bool {
|
||||
if current.first().and_then(|w| w.rsplit(['/', '\\']).next()) == Some("env") {
|
||||
for arg in ¤t[1..] {
|
||||
if arg == "--" {
|
||||
break; // end of env options; the rest is the command
|
||||
// end of env options; the rest is the command
|
||||
break;
|
||||
}
|
||||
if arg.starts_with('-') {
|
||||
return true; // env option alters/clears the exec environment
|
||||
// env option alters/clears the exec environment
|
||||
return true;
|
||||
}
|
||||
match arg.split_once('=') {
|
||||
Some((key, _)) => {
|
||||
@@ -782,7 +785,8 @@ fn command_env_is_unsafe(words: &[String]) -> bool {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
None => break, // first plain word is the inner command
|
||||
// first plain word is the inner command
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -982,7 +986,7 @@ Decisions the user has already made in this conversation are part of their inten
|
||||
/// `{thinking, shouldBlock, reason}` shape the prompt requests and that
|
||||
/// [`parse_classifier_model_text`] parses. Sent as the request `json_schema` so the
|
||||
/// model is constrained to emit conforming JSON — parity with a forced
|
||||
/// `classify_result` tool schema, and removes reliance on best-effort text parsing.
|
||||
/// `classify_result` tool schema, and drops reliance on best-effort text parsing.
|
||||
pub fn classifier_output_json_schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
@@ -2230,7 +2234,7 @@ mod tests {
|
||||
Vec<ClassifierMessage>,
|
||||
tokio::sync::oneshot::Sender<Result<String, String>>,
|
||||
)>();
|
||||
drop(rx); // closed channel
|
||||
drop(rx);
|
||||
let clf = LlmPermissionClassifier::with_channel(tx, ClassifierPromptType::Full);
|
||||
assert!(clf.has_side_query());
|
||||
assert_eq!(
|
||||
@@ -2500,7 +2504,7 @@ mod tests {
|
||||
// The read-only forms stay routine, incl. `--o*` options that are NOT
|
||||
// abbreviations of the pager flag. (`git grep -o` stays Block via the
|
||||
// pre-existing write model, which treats `git ... -o <path>` as an
|
||||
// output flag — format-patch conservatism, unchanged here.)
|
||||
// output flag — format-patch conservatism, `unchanged` here.)
|
||||
assert_eq!(v("git grep -n TODO src"), ClassifierVerdict::Allow);
|
||||
assert_eq!(v("git grep -o TODO src"), ClassifierVerdict::Block);
|
||||
assert_eq!(
|
||||
|
||||
@@ -336,7 +336,7 @@ pub(crate) fn wrapper_has_chdir(words: &[String]) -> bool {
|
||||
|
||||
/// Simple shell-like splitter that:
|
||||
/// - splits on whitespace (outside of quotes)
|
||||
/// - handles single and double quotes, removing the quotes
|
||||
/// - handles single and double quotes, stripping the quotes
|
||||
/// - handles backslash escapes in a basic way
|
||||
fn sh_split_simple(s: &str) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
@@ -525,7 +525,7 @@ fn parse_plain_command_from_node(cmd: Node, src: &str) -> Option<PlainCommand> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Display soft-breaks (permission UI / formatting) ─────────────────
|
||||
// Display soft-breaks (permission UI / formatting)
|
||||
|
||||
/// Node kinds whose descendants are payload text, not shell control flow.
|
||||
/// Operators that appear only as *characters* inside these are not real
|
||||
@@ -679,7 +679,7 @@ pub fn range_fully_inside(start: usize, end: usize, ranges: &[(usize, usize)]) -
|
||||
/// breaks that fall strictly inside the line.
|
||||
///
|
||||
/// When there are no applicable breaks, returns a single-element vec with
|
||||
/// `line` unchanged.
|
||||
/// `line` `unchanged`.
|
||||
pub fn split_physical_line_at_soft_breaks<'a>(
|
||||
line: &'a str,
|
||||
line_start: usize,
|
||||
@@ -975,7 +975,7 @@ mod tests {
|
||||
assert_eq!(primary_command_from_script(sleep_only), None);
|
||||
}
|
||||
|
||||
// ── soft_break_offsets_after_operators ────────────────────────────
|
||||
// soft_break_offsets_after_operators
|
||||
|
||||
/// Helper: operators present at soft-break points (suffix of each prefix).
|
||||
fn break_operator_suffixes(script: &str) -> Vec<String> {
|
||||
@@ -1149,7 +1149,7 @@ mod tests {
|
||||
#[test]
|
||||
fn soft_break_nested_command_sub_still_sees_outer_list() {
|
||||
// Operators inside $() may or may not be soft-breaks depending on
|
||||
// whether we treat command_substitution as payload. We intentionally
|
||||
// whether we treat command_substitution as payload. We deliberately
|
||||
// still allow breaks inside $() (they're real shell ops for that
|
||||
// subshell) — only string/heredoc/comment are payload. Assert outer
|
||||
// list op is present either way.
|
||||
|
||||
@@ -9,9 +9,7 @@ use tracing::{debug, warn};
|
||||
use crate::permission::rules::parse_permission_rule;
|
||||
use crate::permission::types::{PermissionConfig, RuleAction};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Settings Types (Claude JSON subset)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Subset of `.claude/settings.json` we care about.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -257,7 +255,7 @@ fn extract_string_array(value: Option<&serde_json::Value>) -> (Vec<String>, Vec<
|
||||
/// become their string representation. Null, array, and object values are
|
||||
/// skipped with warnings.
|
||||
///
|
||||
/// Note: nulls are intentionally skipped rather than coerced to the literal
|
||||
/// Note: nulls are deliberately skipped rather than coerced to the literal
|
||||
/// `"null"` — setting an env var to `"null"` is rarely useful and more likely a
|
||||
/// user mistake.
|
||||
fn extract_string_map(
|
||||
@@ -324,9 +322,7 @@ impl JsonTypeName for serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Discovery
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// TODO(follow-up): The discovery logic here (find_claude_settings_paths,
|
||||
// collect_project_claude_paths, find_repo_root) is local to this module.
|
||||
@@ -443,9 +439,7 @@ fn find_repo_root(start: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Environment Variables
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Load merged environment variables from Claude settings files, gating the
|
||||
/// repo-tree `.claude/settings.json` `env` on `project_trusted`.
|
||||
@@ -498,13 +492,11 @@ pub fn load_claude_env_with_project(cwd: &Path, project_trusted: bool) -> HashMa
|
||||
merged
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 2 cutoff marker
|
||||
// =============================================================================
|
||||
//
|
||||
// `kigi-shell::claude_import` writes the marker. We re-implement a small
|
||||
// reader here because the gate consumers live in this crate and can't depend
|
||||
// on shell (it would create a cycle). Caching is intentionally omitted; if
|
||||
// on shell (it would create a cycle). Caching is deliberately omitted; if
|
||||
// this becomes a hotspot we can lift it into a shared crate.
|
||||
|
||||
/// True when the user marked Claude settings imported (`[claude_compat].imported`
|
||||
|
||||
@@ -105,7 +105,7 @@ fn mcp_server_prefix_allowed(name: &str, servers: &HashSet<String>) -> bool {
|
||||
}
|
||||
|
||||
/// Pre-decision lookup for an MCP tool. Returns `Some(Decision::Allow)`
|
||||
/// when the user has previously granted "always allow" for this exact
|
||||
/// when the user has already granted "always allow" for this exact
|
||||
/// tool name or for the tool's server prefix. Returns `None` (i.e. fall
|
||||
/// through to the prompt) when no grant exists.
|
||||
///
|
||||
@@ -207,7 +207,7 @@ fn is_safe_command_words_str(cmd: &str) -> bool {
|
||||
|| matches_command_prefix(cmd, "uniq")
|
||||
|| matches_command_prefix(cmd, "tr")
|
||||
|| matches_command_prefix(cmd, "cut")
|
||||
// CWE-863: `tee` removed from safe-command list — it writes stdin
|
||||
// CWE-863: `tee` is not in the safe-command list — it writes stdin
|
||||
// to arbitrary files, enabling pipelines like `cat data | tee /target` to
|
||||
// bypass edit permissions.
|
||||
//
|
||||
@@ -906,11 +906,11 @@ fn spawn_permission_manager_with_pin(
|
||||
let client_id_ref = client_identifier.as_deref();
|
||||
let mut state = load_state_from_disk(&cwd, client_id_ref).await;
|
||||
|
||||
// One-time migration for users who previously selected
|
||||
// One-time migration for users who selected
|
||||
// "Yes, allow all edits during this session".
|
||||
//
|
||||
// Prior to this change, that choice would set edit_policy=Allow and
|
||||
// persist it to ~/.kigi/sessions/<cwd>/permission.toml. This caused
|
||||
// That choice used to set edit_policy=Allow and persist it to
|
||||
// ~/.kigi/sessions/<cwd>/permission.toml. This caused
|
||||
// the allow to survive full restarts (new kigi process, new agent
|
||||
// session in the same directory), which did not match the label or
|
||||
// user expectation (and did not match upstream session-scoped
|
||||
@@ -1356,7 +1356,7 @@ fn spawn_permission_manager_with_pin(
|
||||
//
|
||||
// The session allowlist (`allowed_mcp_tools` /
|
||||
// `allowed_mcp_servers`) short-circuits the prompt
|
||||
// when the user has previously granted "always allow"
|
||||
// when the user has already granted "always allow"
|
||||
// for the tool or its server prefix. A policy `Ask`
|
||||
// rule overrides the allowlist unless
|
||||
// `remember_tool_approvals` is on, in which case an
|
||||
@@ -1760,7 +1760,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::permission::bash_command_splitting::primary_command_from_script;
|
||||
|
||||
// ── Managed-policy pin: yolo clamp + persisted bash clamp ──
|
||||
// Managed-policy pin: yolo clamp + persisted bash clamp
|
||||
|
||||
const PIN: &str = crate::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS;
|
||||
|
||||
@@ -1806,7 +1806,8 @@ mod tests {
|
||||
cwd.clone(),
|
||||
ClientType::Generic,
|
||||
None,
|
||||
vec![], // deny_read_globs
|
||||
// deny_read_globs
|
||||
vec![],
|
||||
vec![],
|
||||
initial_yolo,
|
||||
None,
|
||||
@@ -1827,7 +1828,8 @@ mod tests {
|
||||
cwd.clone(),
|
||||
ClientType::Generic,
|
||||
Some(config),
|
||||
vec![], // deny_read_globs
|
||||
// deny_read_globs
|
||||
vec![],
|
||||
vec![],
|
||||
initial_yolo,
|
||||
None,
|
||||
@@ -2427,7 +2429,8 @@ mod tests {
|
||||
cwd.clone(),
|
||||
client_type,
|
||||
config,
|
||||
vec![], // deny_read_globs
|
||||
// deny_read_globs
|
||||
vec![],
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
@@ -2987,8 +2990,6 @@ mod tests {
|
||||
let (mgr, _e) =
|
||||
manager_with_recording_client(&cwd, None, client, ClientType::Generic);
|
||||
|
||||
// Two non-safe segments (`curl`, `sh`) — previously each opened
|
||||
// its own permission UI with only that segment as the command.
|
||||
let cmd = "curl http://example.com && sh -c 'echo hi'";
|
||||
let d = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
@@ -3586,7 +3587,7 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
// ── Test-only bridging helpers ─────────────────────────────────
|
||||
// Test-only bridging helpers
|
||||
//
|
||||
// The production helpers operate on parsed segment word lists. These
|
||||
// shims preserve the previous string-based test signatures so existing
|
||||
@@ -3952,7 +3953,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── pipe-aware is_safe_command tests (tree-sitter based) ────────
|
||||
// pipe-aware is_safe_command tests (tree-sitter based)
|
||||
|
||||
#[test]
|
||||
fn test_safe_command_pipe_all_safe() {
|
||||
@@ -4052,7 +4053,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_v020_safe_command_rejects_prefix_collisions() {
|
||||
// "truncate" must NOT be considered safe (previously matched "tr")
|
||||
// "truncate" must NOT be considered safe (it shares the "tr" prefix)
|
||||
assert!(!is_safe_command("truncate --size=0 /etc/passwd"));
|
||||
assert!(!is_safe_command("truncate -s 0 important.db"));
|
||||
// "traceroute" must NOT be considered safe
|
||||
@@ -4091,7 +4092,7 @@ mod tests {
|
||||
]));
|
||||
}
|
||||
|
||||
// ── evaluate_bash_segments: per-segment scrutiny tests ─────────
|
||||
// evaluate_bash_segments: per-segment scrutiny tests
|
||||
//
|
||||
// These cover the security bypasses that the previous primary-only
|
||||
// check allowed (`ls && rm -rf`, `cargo test && git push --force`, ...)
|
||||
@@ -4165,7 +4166,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn evaluate_chained_dangerous_with_whitelisted_primary_still_prompts() {
|
||||
// Bypass class 2: a previously approved `cargo test` whitelist
|
||||
// Bypass class 2: an approved `cargo test` whitelist
|
||||
// entry must NOT cause `cargo test && git push --force` to skip
|
||||
// the dangerous-segment prompt.
|
||||
let mut state = PermissionState::default();
|
||||
@@ -4218,7 +4219,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn evaluate_all_whitelisted_chain_auto_allows() {
|
||||
// A user who previously approved `cargo` should have any
|
||||
// A user who approved `cargo` should have any
|
||||
// chain of `cargo *` commands auto-allow, since each segment
|
||||
// matches the whitelist prefix.
|
||||
let mut state = PermissionState::default();
|
||||
|
||||
@@ -167,7 +167,7 @@ impl From<PermissionConfig> for CompiledPolicy {
|
||||
/// The inner script string of a `bash -c "<script>"` invocation (also `sh`,
|
||||
/// `dash`, `zsh`, `ksh`); `None` if the words are not such an invocation.
|
||||
/// Known residuals: option arguments (`-o pipefail`) and `+`-option words can
|
||||
/// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would add a dodge.
|
||||
/// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would open a dodge.
|
||||
fn shell_dash_c_script(words: &[String]) -> Option<&str> {
|
||||
let program = words.first()?.rsplit(['/', '\\']).next()?;
|
||||
if !matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
|
||||
@@ -336,7 +336,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::permission::types::PermissionRule;
|
||||
|
||||
// ── pattern_matches tests ─────────────────────────────────────────────
|
||||
// pattern_matches tests
|
||||
|
||||
fn rule_for(pattern: &str) -> PermissionRule {
|
||||
PermissionRule {
|
||||
@@ -476,7 +476,7 @@ mod tests {
|
||||
assert!(!matches(&AccessKind::Read(None), &rule_for("src/*")));
|
||||
}
|
||||
|
||||
// ── tool_filter_matches tests ──────────────────────────────────────────
|
||||
// tool_filter_matches tests
|
||||
|
||||
#[test]
|
||||
fn test_tool_filter_any() {
|
||||
@@ -557,7 +557,7 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ── evaluate tests ─────────────────────────────────────────────────────
|
||||
// evaluate tests
|
||||
|
||||
fn evaluate_policy(access: &AccessKind, config: &PermissionConfig) -> Option<Decision> {
|
||||
CompiledPolicy::new(config.clone()).evaluate(access)
|
||||
@@ -622,7 +622,7 @@ mod tests {
|
||||
assert!(evaluate_policy(&AccessKind::Bash("ls".into()), &policy).is_none());
|
||||
}
|
||||
|
||||
// ── CompiledPolicy reuse tests ────────────────────────────────────────
|
||||
// CompiledPolicy reuse tests
|
||||
|
||||
#[test]
|
||||
fn test_compiled_policy_reuse_across_evaluations() {
|
||||
@@ -651,7 +651,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── whitespace prefix bypass regression tests ─────────────────
|
||||
// whitespace prefix bypass regression tests
|
||||
|
||||
#[test]
|
||||
fn test_bash_deny_not_bypassed_by_whitespace_prefix() {
|
||||
@@ -684,7 +684,7 @@ mod tests {
|
||||
assert!(matches(&access, &rule_for("rm*")));
|
||||
}
|
||||
|
||||
// ── Deny bypass via shell operators ──────────────────────────────────
|
||||
// Deny bypass via shell operators
|
||||
|
||||
#[test]
|
||||
fn bash_deny_enforced_in_non_leading_command_position() {
|
||||
@@ -739,7 +739,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── default action tests ──────────────────────────────────────
|
||||
// default action tests
|
||||
|
||||
#[test]
|
||||
fn test_rule_action_defaults_to_deny() {
|
||||
@@ -761,7 +761,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── other tests from main ────────────────────────────────────────────
|
||||
// other tests from main
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_respects_deny_policy() {
|
||||
|
||||
@@ -77,7 +77,7 @@ const ENABLE_ALWAYS_APPROVE_LABEL: &str =
|
||||
///
|
||||
/// Note: the pager's `default_selected_permission` + sticky "last used"
|
||||
/// cursor logic (see `DefaultSelectedPermission` + `enqueue_permission`)
|
||||
/// deliberately skips this option via `is_enable_always_approve_option`
|
||||
/// Deliberately skips this option via `is_enable_always_approve_option`
|
||||
/// when a configured or last-used preselection is in play. When neither is
|
||||
/// set, the cursor preselects THIS option explicitly (also via
|
||||
/// `is_enable_always_approve_option`, not by index 0).
|
||||
@@ -225,7 +225,7 @@ pub fn mcp_tool_display_name(tool_name: &str, server_prefix: Option<&str>) -> St
|
||||
/// or scrollback blocks that store the wire name verbatim). Splits on
|
||||
/// the (validated-at-construction) `MCP_TOOL_NAME_DELIMITER`: if the
|
||||
/// split succeeds the name is formatted as `"(Server) Action"` with
|
||||
/// each segment title-cased; otherwise the input is returned unchanged
|
||||
/// each segment title-cased; otherwise the input is returned `unchanged`
|
||||
/// (no title-casing — the input may be a bash command, file path, or
|
||||
/// other non-MCP text that the caller mustn't mangle).
|
||||
pub fn mcp_pretty_name_if_qualified(name: &str) -> String {
|
||||
@@ -1292,7 +1292,7 @@ mod tests {
|
||||
"(Linear) List Issues"
|
||||
);
|
||||
// Non-qualified input (e.g. a bash command, file path, or any
|
||||
// string without `__`) is returned UNCHANGED — must not
|
||||
// string without `__`) is returned `UNCHANGED` — must not
|
||||
// title-case or mangle non-MCP strings.
|
||||
assert_eq!(mcp_pretty_name_if_qualified("read_file"), "read_file");
|
||||
assert_eq!(mcp_pretty_name_if_qualified("cargo test"), "cargo test");
|
||||
@@ -1314,9 +1314,7 @@ mod tests {
|
||||
assert_eq!(mcp_titleize_segment(""), "");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// "Enable always-approve mode" option (prepended for TUI/Pager/Desktop)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn enable_always_approve_id() -> acp::PermissionOptionId {
|
||||
acp::PermissionOptionId::new(ENABLE_ALWAYS_APPROVE_OPTION_ID)
|
||||
@@ -1486,7 +1484,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── events.jsonl emission ─────────────────────────────────
|
||||
// events.jsonl emission
|
||||
|
||||
#[test]
|
||||
fn tool_name_for_access_pins_canonical_names() {
|
||||
@@ -1567,7 +1565,8 @@ mod tests {
|
||||
let writer = EventWriter::open(dir.path());
|
||||
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
drop(rx); // channel closed → request_permission errors immediately
|
||||
// channel closed → request_permission errors immediately
|
||||
drop(rx);
|
||||
let gateway = GatewaySender::new(tx);
|
||||
|
||||
let prompter = AcpPrompter::new(
|
||||
|
||||
@@ -299,9 +299,7 @@ fn managed_config_permissions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Fallback Resolver
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Resolve permission config, merging native Kigi and Claude sources.
|
||||
/// Evaluation is order-independent (deny > ask > allow); merge order affects
|
||||
@@ -707,9 +705,7 @@ fn resolve_claude_settings_inner(
|
||||
))
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// managed-settings.json
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -1384,9 +1380,7 @@ fn normalize_git_url(url: &str) -> String {
|
||||
url.to_lowercase().trim_end_matches(".git").to_string()
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1399,7 +1393,7 @@ mod tests {
|
||||
use crate::ENV_TEST_LOCK as ENV_LOCK;
|
||||
|
||||
// The crate-shared generic env-var guard (one definition in `lib.rs`),
|
||||
// aliased here so the existing `EnvVarGuard::set/unset` call sites are unchanged.
|
||||
// aliased here so the existing `EnvVarGuard::set/unset` call sites are `unchanged`.
|
||||
use crate::TestEnvGuard as EnvVarGuard;
|
||||
|
||||
/// Only `Deny` rules on read-capable tools (Read/Grep/Any) become grep
|
||||
@@ -1416,9 +1410,11 @@ mod tests {
|
||||
rule(RuleAction::Deny, ToolFilter::Read, "**/.env"),
|
||||
rule(RuleAction::Deny, ToolFilter::Any, "**/*.pem"),
|
||||
rule(RuleAction::Deny, ToolFilter::Grep, "**/secret.txt"),
|
||||
rule(RuleAction::Deny, ToolFilter::Edit, "**/.env"), // write-only: excluded
|
||||
rule(RuleAction::Allow, ToolFilter::Read, "src/**"), // allow: excluded
|
||||
rule(RuleAction::Ask, ToolFilter::Read, "**/secrets/**"), // ask: excluded
|
||||
// write-only: excluded
|
||||
rule(RuleAction::Deny, ToolFilter::Edit, "**/.env"),
|
||||
// allow: excluded
|
||||
rule(RuleAction::Allow, ToolFilter::Read, "src/**"),
|
||||
rule(RuleAction::Ask, ToolFilter::Read, "**/secrets/**"),
|
||||
]);
|
||||
assert_eq!(
|
||||
deny_read_globs_from_config(&config),
|
||||
@@ -1628,9 +1624,7 @@ mod tests {
|
||||
assert_eq!(settings.default_mode, Some("acceptEdits".to_string()));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Phase 4: Integration / Precedence Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Integration test: end-to-end flow from .claude/settings.json file
|
||||
/// through load -> into_config -> verify rules are produced.
|
||||
@@ -1760,9 +1754,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// defaultMode + resolve_claude_permissions tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn default_mode_accept_edits_produces_allow_edit_rule() {
|
||||
@@ -1854,9 +1846,7 @@ mod tests {
|
||||
assert_eq!(cfg.rules[1].tool, ToolFilter::Edit);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Environment variable loading tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn load_settings_with_env() {
|
||||
@@ -2018,7 +2008,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── requirements.toml / managed-settings.json permission tests ────
|
||||
// requirements.toml / managed-settings.json permission tests
|
||||
|
||||
#[test]
|
||||
fn parse_toml_compact_deny_rules() {
|
||||
@@ -2114,7 +2104,7 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── managed-settings.json tests ──────────────────────────────────
|
||||
// managed-settings.json tests
|
||||
|
||||
#[test]
|
||||
fn parse_managed_settings_json_end_to_end() {
|
||||
@@ -2310,12 +2300,13 @@ mod tests {
|
||||
let ms = parse_managed_settings_json(&json, path);
|
||||
let al = &ms.mcp_allowlist;
|
||||
|
||||
// All four previously fell through the literal glob (fail-open).
|
||||
// All four earlier fell through the literal glob (fail-open).
|
||||
for bypass in [
|
||||
"https://mcp-gateway.example.net:443/mcp", // explicit port
|
||||
"http://mcp-gateway.example.net/mcp", // scheme swap
|
||||
"https://mcp-gateway.example.net", // path-less host
|
||||
"https://mcp-gateway.example.net./mcp", // trailing-dot FQDN
|
||||
"https://mcp-gateway.example.net:443/mcp",
|
||||
"http://mcp-gateway.example.net/mcp",
|
||||
"https://mcp-gateway.example.net",
|
||||
// trailing-dot FQDN
|
||||
"https://mcp-gateway.example.net./mcp",
|
||||
] {
|
||||
assert!(!al.is_http_allowed(bypass), "must be denied: {bypass}");
|
||||
}
|
||||
@@ -2407,7 +2398,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── serverName MCP policy matching ───────────────────────────────
|
||||
// serverName MCP policy matching
|
||||
|
||||
fn http_named(name: &str, url: &str) -> agent_client_protocol::McpServer {
|
||||
agent_client_protocol::McpServer::Http(
|
||||
@@ -2618,9 +2609,7 @@ mod tests {
|
||||
assert!(!al.is_url_allowed("git@evil.com:ACME/repo.git"));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Bare tool name parsing tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn parse_bare_bash_tool_name() {
|
||||
@@ -2665,9 +2654,7 @@ mod tests {
|
||||
assert_eq!(rule.pattern, Some("npm test".to_string()));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Cross-file permission merging tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn merge_permissions_across_project_and_global_settings() {
|
||||
@@ -2877,9 +2864,7 @@ mod tests {
|
||||
assert!(path.ends_with(".claude/settings.json"));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// bypassPermissions defaultMode tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn bypass_permissions_produces_catch_all_allow() {
|
||||
@@ -3095,7 +3080,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The native `[ui] disable_bypass_permissions_mode` key locks when true
|
||||
/// (default false). `permission_mode` is intentionally not a lock key.
|
||||
/// (default false). `permission_mode` is deliberately not a lock key.
|
||||
#[test]
|
||||
fn disable_bypass_permissions_mode_locks_when_true() {
|
||||
let p = Path::new("test-requirements.toml");
|
||||
@@ -4000,9 +3985,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Additional tool prefix tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn parse_glob_tool_prefix() {
|
||||
@@ -4030,9 +4013,7 @@ mod tests {
|
||||
assert_eq!(rule.tool, ToolFilter::Edit);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Escaped parentheses tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn parse_escaped_parens_in_content() {
|
||||
@@ -4050,9 +4031,7 @@ mod tests {
|
||||
assert_eq!(rule.pattern, Some(r"echo test\nvalue".to_string()));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Bash(*) and Bash() normalization tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn bash_star_is_tool_wide() {
|
||||
|
||||
@@ -69,9 +69,7 @@ pub(crate) struct DefaultModeEffects {
|
||||
pub(crate) bypass_permissions: bool,
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Error Type
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Errors from parsing a permission rule string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -102,9 +100,7 @@ impl std::fmt::Display for RuleParseError {
|
||||
|
||||
impl std::error::Error for RuleParseError {}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Rule Parser
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Parse a permission rule string into a native `PermissionRule`.
|
||||
///
|
||||
|
||||
@@ -258,7 +258,8 @@ fn runs_in_current_shell(cmd: Node<'_>) -> bool {
|
||||
let mut node = cmd;
|
||||
loop {
|
||||
if node.next_sibling().is_some_and(|s| s.kind() == "&") {
|
||||
return false; // backgrounded subshell
|
||||
// backgrounded subshell
|
||||
return false;
|
||||
}
|
||||
let Some(parent) = node.parent() else {
|
||||
return true;
|
||||
@@ -297,7 +298,7 @@ fn cwd_poison_positions(root: Node<'_>, src: &str) -> Vec<usize> {
|
||||
positions
|
||||
}
|
||||
|
||||
/// Whether an operand at `at` runs after a cwd change, so it can't be pinned.
|
||||
/// Whether an operand at `at` runs after a cwd shift, so it can't be pinned.
|
||||
fn cwd_unpinned_before(positions: &[usize], at: usize) -> bool {
|
||||
positions.iter().any(|&p| p < at)
|
||||
}
|
||||
@@ -371,7 +372,7 @@ fn shell_node_arg(node: Node<'_>, src: &str) -> Option<ArgText> {
|
||||
}
|
||||
|
||||
/// Every `command` node (incl. nested) as `(start_byte, words, ambiguous)`, in
|
||||
/// source order. `start_byte` orders invocations against cwd-change positions.
|
||||
/// source order. `start_byte` orders invocations against cwd-shift positions.
|
||||
fn shell_command_invocations(root: Node<'_>, src: &str) -> Vec<(usize, Vec<String>, bool)> {
|
||||
let mut found: Vec<(usize, Vec<String>, bool)> = Vec::new();
|
||||
let mut stack = vec![root];
|
||||
@@ -528,7 +529,7 @@ fn special_file_operands(program: &str, words: &[String]) -> Vec<(String, ShellF
|
||||
})
|
||||
.collect(),
|
||||
// `--output`/`-o` write the output file. (`git`'s `-O` is a READ
|
||||
// order-file, NOT a write, so it is intentionally excluded.)
|
||||
// order-file, NOT a write, so it is deliberately excluded.)
|
||||
"sort" | "go" | "git" => shell_output_flag_values(words)
|
||||
.map(|output| (output.to_owned(), ShellFileMode::Write))
|
||||
.collect(),
|
||||
@@ -1094,18 +1095,25 @@ mod tests {
|
||||
let policy = compiled(vec![file_rule(
|
||||
RuleAction::Deny,
|
||||
ToolFilter::Read,
|
||||
"/repo-b/.env", // path-scoped: matching would need the untracked cd target
|
||||
// path-scoped: matching would need the untracked cd target
|
||||
"/repo-b/.env",
|
||||
)]);
|
||||
let session = std::path::Path::new("/repo-a");
|
||||
for cmd in [
|
||||
"cd /repo-b && cat .env", // cd in the current shell
|
||||
"pushd /repo-b; cat .env", // pushd is never folded
|
||||
"if true; then cd /repo-b; fi; cat .env", // conditional cd
|
||||
"env -C /repo-b cat .env", // env chdir wrapper
|
||||
// cd in the current shell
|
||||
"cd /repo-b && cat .env",
|
||||
// pushd is never folded
|
||||
"pushd /repo-b; cat .env",
|
||||
"if true; then cd /repo-b; fi; cat .env",
|
||||
// env chdir wrapper
|
||||
"env -C /repo-b cat .env",
|
||||
"env --chdir=/repo-b cat .env",
|
||||
"/usr/bin/env -C /repo-b cat .env", // path-qualified env
|
||||
"env FOO=1 -C /repo-b cat .env", // chdir after an assignment
|
||||
"cd /repo-b && echo x > .env", // redirect operand too
|
||||
// path-qualified env
|
||||
"/usr/bin/env -C /repo-b cat .env",
|
||||
// chdir after an assignment
|
||||
"env FOO=1 -C /repo-b cat .env",
|
||||
// redirect operand too
|
||||
"cd /repo-b && echo x > .env",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
@@ -1142,7 +1150,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `cd` in a pipeline/subshell/backgrounded `&` doesn't change a sibling's
|
||||
/// A `cd` in a pipeline/subshell/backgrounded `&` does not alter a sibling's
|
||||
/// cwd, so their reads resolve against the original cwd, not the `cd` target.
|
||||
#[test]
|
||||
fn shell_cd_does_not_scope_across_pipe_subshell_or_background() {
|
||||
@@ -1153,9 +1161,12 @@ mod tests {
|
||||
"/work/secret.env",
|
||||
)]);
|
||||
for cmd in [
|
||||
"cd /elsewhere | cat secret.env", // pipeline segment: own subshell
|
||||
"(cd /elsewhere); cat secret.env", // subshell ended with `;`
|
||||
"cd /elsewhere & cat secret.env", // backgrounded cd
|
||||
// pipeline segment: own subshell
|
||||
"cd /elsewhere | cat secret.env",
|
||||
// subshell ended with `;`
|
||||
"(cd /elsewhere); cat secret.env",
|
||||
// backgrounded cd
|
||||
"cd /elsewhere & cat secret.env",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
@@ -1328,10 +1339,13 @@ mod tests {
|
||||
"**/.env",
|
||||
)]);
|
||||
for cmd in [
|
||||
"rg secret", // no path: searches cwd
|
||||
"ack secret", // no path
|
||||
"rg secret .", // directory operand
|
||||
"rg secret src/", // directory operand
|
||||
// no path: searches cwd
|
||||
"rg secret",
|
||||
"ack secret",
|
||||
// directory operand
|
||||
"rg secret .",
|
||||
// directory operand
|
||||
"rg secret src/",
|
||||
"ag secret .",
|
||||
] {
|
||||
assert!(
|
||||
@@ -1373,7 +1387,7 @@ mod tests {
|
||||
/// `[permission]` tier. Tool mapping: `Read`→Read, `Write`/`Edit`→Edit, `Bash`→Bash.
|
||||
fn enterprise_requirements_policy() -> CompiledPolicy {
|
||||
compiled(vec![
|
||||
// ── ask = [...] ──
|
||||
// ask = [...]
|
||||
bash_rule(RuleAction::Ask, "kubectl *"),
|
||||
bash_rule(RuleAction::Ask, "terraform apply *"),
|
||||
bash_rule(RuleAction::Ask, "aws *"),
|
||||
@@ -1383,10 +1397,10 @@ mod tests {
|
||||
bash_rule(RuleAction::Ask, "security *"),
|
||||
bash_rule(RuleAction::Ask, "op *"),
|
||||
file_rule(RuleAction::Ask, ToolFilter::Read, "**/secrets/**"),
|
||||
file_rule(RuleAction::Ask, ToolFilter::Edit, "**/secrets/**"), // Write(..)
|
||||
file_rule(RuleAction::Ask, ToolFilter::Edit, "**/secrets/**"), // Edit(..)
|
||||
file_rule(RuleAction::Ask, ToolFilter::Edit, "**/secrets/**"),
|
||||
file_rule(RuleAction::Ask, ToolFilter::Edit, "**/secrets/**"),
|
||||
file_rule(RuleAction::Ask, ToolFilter::Read, "**/Library/Mail/**"),
|
||||
// ── deny = [...] ──
|
||||
// deny = [...]
|
||||
bash_rule(RuleAction::Deny, "rm -rf *"),
|
||||
bash_rule(RuleAction::Deny, "sudo *"),
|
||||
bash_rule(RuleAction::Deny, "su *"),
|
||||
@@ -1416,23 +1430,23 @@ mod tests {
|
||||
ToolFilter::Read,
|
||||
"**/Library/Keychains/**",
|
||||
),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.env*"), // Write(..)
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.ssh/**"), // Write(..)
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.pem"), // Write(..)
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.key"), // Write(..)
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.p12"), // Write(..)
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.internal-deploy/**"), // Write(..)
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/terraform.tfstate"), // Write(..)
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.env*"),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.ssh/**"),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.pem"),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.key"),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.p12"),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.internal-deploy/**"),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/terraform.tfstate"),
|
||||
file_rule(
|
||||
RuleAction::Deny,
|
||||
ToolFilter::Edit,
|
||||
"**/terraform.tfstate.backup",
|
||||
), // Write(..)
|
||||
),
|
||||
file_rule(
|
||||
RuleAction::Deny,
|
||||
ToolFilter::Edit,
|
||||
"**/Library/Keychains/**",
|
||||
), // Write(..)
|
||||
),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.env"),
|
||||
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.env.*"),
|
||||
])
|
||||
@@ -1606,10 +1620,10 @@ mod tests {
|
||||
|
||||
let policy = enterprise_requirements_policy();
|
||||
let matrix: &[(&str, Vector, Expect)] = &[
|
||||
// ── file-read tool: real sentinel files (setup.sh) ──
|
||||
// file-read tool: real sentinel files (setup.sh)
|
||||
("read .env", ReadTool(".env"), Deny),
|
||||
("read .env.staging", ReadTool(".env.staging"), Deny), // **/.env.*
|
||||
("read src/server.pem", ReadTool("src/server.pem"), Deny), // **/*.pem
|
||||
("read .env.staging", ReadTool(".env.staging"), Deny),
|
||||
("read src/server.pem", ReadTool("src/server.pem"), Deny),
|
||||
(
|
||||
"read terraform.tfstate",
|
||||
ReadTool("terraform.tfstate"),
|
||||
@@ -1619,10 +1633,10 @@ mod tests {
|
||||
"read secrets/api_key.txt",
|
||||
ReadTool("secrets/api_key.txt"),
|
||||
Ask,
|
||||
), // **/secrets/**
|
||||
),
|
||||
("read README.md (neg)", ReadTool("README.md"), Allowed),
|
||||
("read src/main.py (neg)", ReadTool("src/main.py"), Allowed),
|
||||
// ── file-read tool: every remaining deny/ask glob in the policy ──
|
||||
// file-read tool: every remaining deny/ask glob in the policy
|
||||
("read *.key", ReadTool("config/id_rsa.key"), Deny),
|
||||
("read *.p12", ReadTool("cert.p12"), Deny),
|
||||
("read *.pfx", ReadTool("cert.pfx"), Deny),
|
||||
@@ -1657,17 +1671,18 @@ mod tests {
|
||||
ReadTool("Library/Mail/Inbox.mbox"),
|
||||
Ask,
|
||||
),
|
||||
// ── file-read tool: lookalike negatives (must NOT match) ──
|
||||
// file-read tool: lookalike negatives (must NOT match)
|
||||
("read key.pem.txt (neg)", ReadTool("key.pem.txt"), Allowed),
|
||||
(
|
||||
"read my.env.example (neg)",
|
||||
ReadTool("my.env.example"),
|
||||
Allowed,
|
||||
),
|
||||
// ── write/edit tool: Write(..)/Edit(..) denies + secrets ask ──
|
||||
// write/edit tool: Write(..)/Edit(..) denies + secrets ask
|
||||
("edit .env", EditTool(".env"), Deny),
|
||||
("edit .env.local", EditTool(".env.local"), Deny), // **/.env* and **/.env.*
|
||||
("edit src/server.pem", EditTool("src/server.pem"), Deny), // Write(**/*.pem)
|
||||
("edit .env.local", EditTool(".env.local"), Deny),
|
||||
// Write(**/*.pem)
|
||||
("edit src/server.pem", EditTool("src/server.pem"), Deny),
|
||||
("edit *.key", EditTool("config/id_rsa.key"), Deny),
|
||||
("edit *.p12", EditTool("cert.p12"), Deny),
|
||||
(
|
||||
@@ -1696,13 +1711,13 @@ mod tests {
|
||||
("edit *.pfx (no write rule)", EditTool("cert.pfx"), Allowed),
|
||||
("edit README.md (neg)", EditTool("README.md"), Allowed),
|
||||
("edit src/main.py (neg)", EditTool("src/main.py"), Allowed),
|
||||
// ── bash command rules: deny set ──
|
||||
// bash command rules: deny set
|
||||
("bash rm -rf", Bash("rm -rf /tmp/x"), Deny),
|
||||
("bash sudo", Bash("sudo apt-get update"), Deny),
|
||||
("bash su", Bash("su - root"), Deny),
|
||||
// ssh to *.corp.example is deny even though `ssh *` is ask (deny wins).
|
||||
("bash ssh corp-example", Bash("ssh prod.corp.example"), Deny),
|
||||
// ── bash command rules: ask set ──
|
||||
// bash command rules: ask set
|
||||
("bash kubectl", Bash("kubectl get pods -A"), Ask),
|
||||
(
|
||||
"bash terraform apply",
|
||||
@@ -1719,10 +1734,10 @@ mod tests {
|
||||
Ask,
|
||||
),
|
||||
("bash op", Bash("op read op://vault/item"), Ask),
|
||||
// ── bash command rules: negatives ──
|
||||
// bash command rules: negatives
|
||||
("bash ls (neg)", Bash("ls -la"), Allowed),
|
||||
("bash git status (neg)", Bash("git status"), Allowed),
|
||||
// ── shell file-access gate: readers / redirects / substitutions ──
|
||||
// shell file-access gate: readers / redirects / substitutions
|
||||
("sh cat .env", Shell("cat .env"), Deny),
|
||||
("sh cat .env.staging", Shell("cat .env.staging"), Deny),
|
||||
("sh cat src/server.pem", Shell("cat src/server.pem"), Deny),
|
||||
@@ -1733,30 +1748,33 @@ mod tests {
|
||||
),
|
||||
("sh grep FAKE .env", Shell("grep FAKE .env"), Deny),
|
||||
("sh base64 .env", Shell("base64 .env"), Deny),
|
||||
("sh cat 0<.env", Shell("cat 0<.env"), Deny), // fd-prefixed read redirect
|
||||
("sh cat<.env", Shell("cat<.env"), Deny), // glued read redirect
|
||||
("sh cat $(echo .env)", Shell("cat $(echo .env)"), Ask), // unpinnable substitution
|
||||
// fd-prefixed read redirect
|
||||
("sh cat 0<.env", Shell("cat 0<.env"), Deny),
|
||||
// glued read redirect
|
||||
("sh cat<.env", Shell("cat<.env"), Deny),
|
||||
// unpinnable substitution
|
||||
("sh cat $(echo .env)", Shell("cat $(echo .env)"), Ask),
|
||||
(
|
||||
"sh diff <(cat .env)",
|
||||
Shell("diff <(cat .env) /dev/null"),
|
||||
Deny,
|
||||
), // process sub
|
||||
("sh cat subdir/../.env", Shell("cat subdir/../.env"), Deny), // `..` traversal
|
||||
),
|
||||
("sh cat subdir/../.env", Shell("cat subdir/../.env"), Deny),
|
||||
("sh cat .ssh/id_rsa", Shell("cat .ssh/id_rsa"), Deny),
|
||||
// ── shell file-access gate: writers (file must stay unchanged) ──
|
||||
("sh echo > .env", Shell("echo HACKED > .env"), Deny), // write redirect
|
||||
// shell file-access gate: writers (file must stay unchanged)
|
||||
("sh echo > .env", Shell("echo HACKED > .env"), Deny),
|
||||
(
|
||||
"sh sed -ni .env",
|
||||
Shell("sed -ni s/FAKE/HACKED/ .env"),
|
||||
Deny,
|
||||
), // in-place sed
|
||||
("sh tee .env", Shell("printf HACKED | tee .env"), Deny), // pipe into tee
|
||||
),
|
||||
("sh tee .env", Shell("printf HACKED | tee .env"), Deny),
|
||||
(
|
||||
"sh echo > tfstate",
|
||||
Shell("echo x > terraform.tfstate"),
|
||||
Deny,
|
||||
),
|
||||
// ── shell file-access gate: secrets ask + negatives ──
|
||||
// shell file-access gate: secrets ask + negatives
|
||||
(
|
||||
"sh cat secrets/api_key.txt",
|
||||
Shell("cat secrets/api_key.txt"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![allow(dead_code)] // Phase 1 internal helpers
|
||||
// Phase 1 internal helpers
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::permission::types::EditPolicy;
|
||||
use kigi_paths::AbsPathBuf;
|
||||
@@ -151,7 +152,7 @@ pub async fn cleanup_stale_permission_state(max_age: std::time::Duration) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── PermissionState serialization roundtrip tests ─────────────
|
||||
// PermissionState serialization roundtrip tests
|
||||
|
||||
#[test]
|
||||
fn default_state_serialization() {
|
||||
@@ -385,7 +386,7 @@ allowed_bash_commands = ["ls"]
|
||||
assert!(state.disallowed_bash_commands.is_empty());
|
||||
}
|
||||
|
||||
// ── Disk persistence roundtrip tests ─────────────────────────
|
||||
// Disk persistence roundtrip tests
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_and_load_roundtrip() {
|
||||
@@ -441,7 +442,7 @@ allowed_bash_commands = ["ls"]
|
||||
assert!(state.allowed_bash_commands.is_empty());
|
||||
}
|
||||
|
||||
// ── Per-client state file path tests ──────────────────────────
|
||||
// Per-client state file path tests
|
||||
|
||||
#[test]
|
||||
fn state_file_path_without_client_id() {
|
||||
|
||||
@@ -8,7 +8,6 @@ use tokio::sync::oneshot;
|
||||
pub struct PermissionEvent {
|
||||
/// Tool call ID from the model
|
||||
pub tool_id: String,
|
||||
/// Name of the tool being executed
|
||||
pub tool_name: String,
|
||||
/// Type of access requested (read, edit, bash, mcp)
|
||||
pub access_kind: String,
|
||||
@@ -19,7 +18,6 @@ pub struct PermissionEvent {
|
||||
pub yolo_mode: bool,
|
||||
/// Whether this was auto-approved (by YOLO mode or policy rules)
|
||||
pub auto_approved: bool,
|
||||
/// Whether the user was prompted for this decision
|
||||
pub user_prompted: bool,
|
||||
/// The final decision (allow, reject)
|
||||
pub decision: String,
|
||||
@@ -27,10 +25,8 @@ pub struct PermissionEvent {
|
||||
/// etc.); None on auto/non-prompt decisions. The trigger lives in `decision_reason`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_outcome: Option<String>,
|
||||
/// Rejection reason if rejected
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reject_reason: Option<String>,
|
||||
/// When this decision was made
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// If this permission was requested by a subagent, the subagent's session ID.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -318,7 +314,6 @@ pub enum PromptPolicy {
|
||||
/// Seeded into the permission manager's auto flag at session start.
|
||||
Auto,
|
||||
}
|
||||
/// A single permission rule.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionRule {
|
||||
pub action: RuleAction,
|
||||
@@ -335,7 +330,6 @@ pub enum PatternMode {
|
||||
Glob,
|
||||
Domain,
|
||||
}
|
||||
/// Action to take when rule matches.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RuleAction {
|
||||
@@ -344,7 +338,6 @@ pub enum RuleAction {
|
||||
Deny,
|
||||
Ask,
|
||||
}
|
||||
/// Tool filter for permission rules.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ToolFilter {
|
||||
|
||||
@@ -20,12 +20,10 @@ use std::collections::{HashMap, HashSet};
|
||||
/// - `None` — a turn hook (`on_before_turn`/`on_after_turn`).
|
||||
/// - `Some(idx)` — a rewind RPC arm (`begin_prompt`/`end_prompt`).
|
||||
pub(crate) enum TurnBoundary {
|
||||
/// Turn start.
|
||||
Start {
|
||||
prompt_index: Option<usize>,
|
||||
turn_number: u64,
|
||||
},
|
||||
/// Turn end.
|
||||
End {
|
||||
prompt_index: Option<usize>,
|
||||
turn_number: u64,
|
||||
@@ -82,7 +80,6 @@ impl TurnBoundary {
|
||||
/// a blob written before a later field existed still deserializes (field `None`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RewindCheckpoint {
|
||||
/// The prompt this checkpoint belongs to.
|
||||
pub prompt_index: usize,
|
||||
/// Filesystem before/after snapshots for the prompt.
|
||||
pub fs: RewindPoint,
|
||||
@@ -225,7 +222,7 @@ impl WorkspaceHandle {
|
||||
/// Keyed on `prompt_index`: turn hooks (`None`) drive activity; rewind RPC
|
||||
/// arms (`Some`) drive rewind capture (FS, plus git/hunks when their flags
|
||||
/// are on). `workspace_rewind_all_outcomes` also finalizes the open FS
|
||||
/// checkpoint on non-`Completed` turn-ends (gap #2).
|
||||
/// checkpoint on non-`Completed` turn-ends.
|
||||
pub(crate) async fn on_turn_boundary(&self, session_id: &str, boundary: TurnBoundary) {
|
||||
match boundary {
|
||||
TurnBoundary::Start {
|
||||
|
||||
@@ -362,7 +362,7 @@ fn load_capped_from_disk(dir: &Path, cap: usize) -> BTreeMap<usize, RewindCheckp
|
||||
let Some(idx) = parse_checkpoint_index(&file_name) else {
|
||||
// Sweep orphaned temp files from a crashed `write_checkpoint_file`:
|
||||
// rehydrate runs once at construction before this instance writes, so
|
||||
// removing them is safe and bounds clutter. Best-effort.
|
||||
// dropping them is safe and bounds clutter. Best-effort.
|
||||
if is_orphan_checkpoint_tmp(&file_name) {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
@@ -691,7 +691,7 @@ mod tests {
|
||||
"no separators for {raw:?}: {s:?}"
|
||||
);
|
||||
assert!(s != "." && s != "..", "not a traversal component: {s:?}");
|
||||
// Joining must stay within the store root and add exactly one path
|
||||
// Joining must stay within the store root and join exactly one path
|
||||
// component (no `..` escape).
|
||||
let joined = root.join(&s);
|
||||
assert!(joined.starts_with(root), "stays in root: {joined:?}");
|
||||
|
||||
@@ -572,7 +572,8 @@ impl FileStateTracker {
|
||||
let mut source = self.lazy_source.lock().await;
|
||||
// Clone the path so we can clear `source` after a successful read.
|
||||
let Some(path) = source.clone() else {
|
||||
return; // already loaded, or never lazy
|
||||
// already loaded, or never lazy
|
||||
return;
|
||||
};
|
||||
let loaded = match read_rewind_points_file(&path) {
|
||||
Ok(points) => points,
|
||||
@@ -659,7 +660,7 @@ impl FileStateTracker {
|
||||
///
|
||||
/// NOTE: This method is similar to `capture_file_state_with_fs`. They are kept
|
||||
/// separate due to type system constraints (`AsyncFileSystem` trait vs `AsyncFsWrapper`
|
||||
/// concrete type). Keep them in sync when making changes.
|
||||
/// concrete type). Keep them in sync when editing either side.
|
||||
pub async fn capture_file_state<F: AsyncFileSystem + ?Sized>(
|
||||
&self,
|
||||
fs: &F,
|
||||
@@ -677,7 +678,8 @@ impl FileStateTracker {
|
||||
// Not currently processing a prompt, skip capture
|
||||
return Ok(());
|
||||
};
|
||||
drop(current); // Release lock before async operations
|
||||
// Release lock before async operations
|
||||
drop(current);
|
||||
|
||||
// Read current file content (or None if it doesn't exist)
|
||||
let content = fs
|
||||
@@ -704,7 +706,7 @@ impl FileStateTracker {
|
||||
///
|
||||
/// NOTE: This method is similar to `capture_file_state`. They are kept separate
|
||||
/// due to type system constraints (`AsyncFsWrapper` concrete type vs generic
|
||||
/// `AsyncFileSystem` trait). Keep them in sync when making changes.
|
||||
/// `AsyncFileSystem` trait). Keep them in sync when editing either side.
|
||||
pub async fn capture_file_state_with_fs(
|
||||
&self,
|
||||
fs: &AsyncFsWrapper,
|
||||
@@ -722,7 +724,8 @@ impl FileStateTracker {
|
||||
// Not currently processing a prompt, skip capture
|
||||
return Ok(());
|
||||
};
|
||||
drop(current); // Release lock before async operations
|
||||
// Release lock before async operations
|
||||
drop(current);
|
||||
|
||||
// Read current file content (or None if it doesn't exist)
|
||||
let content = fs
|
||||
@@ -826,7 +829,7 @@ impl FileStateTracker {
|
||||
result
|
||||
}
|
||||
|
||||
/// Get a specific rewind point by prompt index. Intentionally does NOT trigger
|
||||
/// Get a specific rewind point by prompt index. Deliberately does NOT trigger
|
||||
/// the historical load: this is the live persistence path (a just-completed
|
||||
/// prompt's point is always in memory), so resume-then-work stays fast.
|
||||
pub async fn get_rewind_point(&self, prompt_index: usize) -> Option<RewindPoint> {
|
||||
@@ -1067,7 +1070,8 @@ impl FileStateHandle {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ToolContext; // from stub above
|
||||
// from stub above
|
||||
use super::ToolContext;
|
||||
use super::*;
|
||||
use crate::file_system::MockFs;
|
||||
use kigi_paths::AbsPathBuf;
|
||||
@@ -1392,7 +1396,7 @@ mod tests {
|
||||
assert!(abs_json.contains("\"path\":\"/abs/path/file.txt\""));
|
||||
}
|
||||
|
||||
// ── Lazy historical rewind-point loading ──────────────────────────────────
|
||||
// Lazy historical rewind-point loading
|
||||
|
||||
/// Build a rewind point at `idx` with the given (relative path, content) files.
|
||||
fn point_with_files(idx: usize, files: &[(&str, &str)]) -> RewindPoint {
|
||||
@@ -1444,7 +1448,6 @@ mod tests {
|
||||
assert_eq!(points.len(), 2);
|
||||
assert_eq!(points[0].prompt_index, 0);
|
||||
assert_eq!(points[1].prompt_index, 1);
|
||||
// Now singular lookups see the loaded points.
|
||||
assert!(tracker.get_rewind_point(0).await.is_some());
|
||||
}
|
||||
|
||||
@@ -1555,9 +1558,9 @@ mod tests {
|
||||
|
||||
let metas = tracker.get_rewind_point_metas().await;
|
||||
assert_eq!(metas.len(), 2);
|
||||
assert_eq!(metas[0].prompt_index, 0); // from disk
|
||||
assert_eq!(metas[0].prompt_index, 0);
|
||||
assert_eq!(metas[0].num_file_snapshots, 1);
|
||||
assert_eq!(metas[1].prompt_index, 1); // from memory
|
||||
assert_eq!(metas[1].prompt_index, 1);
|
||||
assert_eq!(metas[1].num_file_snapshots, 1);
|
||||
}
|
||||
|
||||
@@ -1666,7 +1669,7 @@ mod tests {
|
||||
assert_eq!(metas[1].num_file_snapshots, 1);
|
||||
}
|
||||
|
||||
// ── pure merge_rewind_points_from branch coverage ────────────────────────
|
||||
// pure merge_rewind_points_from branch coverage
|
||||
|
||||
#[test]
|
||||
fn merge_pure_target_zero_clears_all() {
|
||||
|
||||
@@ -940,7 +940,7 @@ fn change_type_from_porcelain(ch: char, staged: bool) -> ChangeType {
|
||||
}
|
||||
/// Parse `git diff --numstat` output into a map of path → (additions, deletions).
|
||||
///
|
||||
/// We intentionally omit `-M` from our `git diff --numstat` invocations, so rename
|
||||
/// We deliberately omit `-M` from our `git diff --numstat` invocations, so rename
|
||||
/// entries won't appear in practice. The format is simply `ADDS\tDELS\tPATH`
|
||||
/// (or `-\t-\tPATH` for binary files).
|
||||
fn parse_numstat(output: &str) -> HashMap<String, (u64, u64)> {
|
||||
|
||||
@@ -370,7 +370,7 @@ impl WorkspaceSession {
|
||||
///
|
||||
/// TOOL-STATE CAVEAT: the outgoing toolset is not flushed here, so an
|
||||
/// in-process rebuild can drop up to one debounce window (≤500 ms) of
|
||||
/// unpersisted state. Intentionally not "fixed" with a flush-before-rebuild:
|
||||
/// unpersisted state. Deliberately not "fixed" with a flush-before-rebuild:
|
||||
/// tool `call()` does not hold `update_lock`, so a concurrent call would
|
||||
/// still race. Restart/snapshot scenarios are unaffected.
|
||||
pub(crate) fn replace(
|
||||
@@ -486,7 +486,7 @@ pub struct WorkspaceShared {
|
||||
pub(crate) session_event_writers:
|
||||
Arc<dashmap::DashMap<String, kigi_file_utils::events::EventWriter>>,
|
||||
/// `(path, size, mtime_ms) → sha256` memo for the client-facing
|
||||
/// `workspace.client_fs_*` ops, so unchanged files hash once per
|
||||
/// `workspace.client_fs_*` ops, so `unchanged` files hash once per
|
||||
/// workspace instead of per stat/read.
|
||||
/// Test-only seam: runs after the toolset re-resolve returns and before
|
||||
/// the post-resolve turn re-check / install in
|
||||
|
||||
@@ -315,8 +315,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
||||
IDS.clone()
|
||||
}
|
||||
}
|
||||
/// Build web fetch config. Enabled with default params unless
|
||||
/// `KIGI_DISABLE_WEB_FETCH=1` is set.
|
||||
/// Enabled with default params unless `KIGI_DISABLE_WEB_FETCH=1` is set.
|
||||
fn build_web_fetch_config() -> kigi_tools::implementations::kigi::web_fetch::WebFetchConfig {
|
||||
use kigi_tools::implementations::kigi::web_fetch::{WebFetchConfig, WebFetchParams};
|
||||
if std::env::var("KIGI_DISABLE_WEB_FETCH").is_ok_and(|v| v == "1" || v == "true") {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Default timing/threshold values ──────────────────────────────────────
|
||||
// Default timing/threshold values
|
||||
// Single source of truth for the `StatusConfig::default()` values and the
|
||||
// documented fallbacks for each `KIGI_WORKSPACE_*` env var.
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ impl TrustStore {
|
||||
.contains_key(canonical.to_string_lossy().as_ref())
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────
|
||||
// Internal
|
||||
|
||||
/// Shared write path for [`Self::set_trusted`] / [`Self::set_untrusted`].
|
||||
///
|
||||
@@ -234,7 +234,7 @@ impl TrustStore {
|
||||
/// rather than clobbered (lost-update fix);
|
||||
/// 3. insert the record and persist atomically;
|
||||
/// 4. only on success commit the new document to memory — on any
|
||||
/// lock/persist error `self.doc` is left unchanged.
|
||||
/// lock/persist error `self.doc` is left `unchanged`.
|
||||
fn record_decision(&mut self, workspace_key: &Path, trusted: bool) -> io::Result<()> {
|
||||
let canonical = canonicalize_or_owned(workspace_key);
|
||||
if is_unsafe_trust_root(&canonical) {
|
||||
@@ -281,7 +281,7 @@ impl TrustStore {
|
||||
);
|
||||
|
||||
// Commit to memory only after a successful durable write, so a failure
|
||||
// leaves the in-memory store unchanged.
|
||||
// leaves the in-memory store `unchanged`.
|
||||
Self::persist_doc(path, &doc)?;
|
||||
self.doc = doc;
|
||||
Ok(())
|
||||
@@ -641,7 +641,7 @@ mod tests {
|
||||
fn migrate_legacy_hook_trust_is_noop_when_file_absent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store_path = tmp.path().join(TRUST_FILE_NAME);
|
||||
let legacy = tmp.path().join("trusted-hook-projects"); // never created
|
||||
let legacy = tmp.path().join("trusted-hook-projects");
|
||||
|
||||
let mut store = TrustStore::load_from(store_path);
|
||||
let migrated = migrate_legacy_hook_trust_in(&legacy, &mut store);
|
||||
@@ -684,7 +684,8 @@ mod tests {
|
||||
// `persist_failure_leaves_memory_unchanged`, robust even when run as root).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store_path = tmp.path().join(TRUST_FILE_NAME);
|
||||
std::fs::create_dir_all(&store_path).unwrap(); // store path is a dir, not a file
|
||||
// store path is a dir, not a file
|
||||
std::fs::create_dir_all(&store_path).unwrap();
|
||||
let project = tmp.path().join("repo");
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
let project_key = canonicalize_or_owned(&project);
|
||||
@@ -954,7 +955,8 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store_path = tmp.path().join(TRUST_FILE_NAME);
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return; // no home dir in this environment; nothing to assert
|
||||
// no home dir in this environment; nothing to assert
|
||||
return;
|
||||
};
|
||||
|
||||
let mut store = TrustStore::load_from(store_path.clone());
|
||||
@@ -1112,7 +1114,8 @@ mod tests {
|
||||
// A hand-edited / migrated `[folders."<home>"]` record must not trust
|
||||
// repos under $HOME — the read side ignores it, matching set_trusted.
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return; // no home dir in this environment; nothing to assert
|
||||
// no home dir in this environment; nothing to assert
|
||||
return;
|
||||
};
|
||||
let canonical_home = canonicalize_or_owned(&home);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1255,10 +1258,11 @@ mod tests {
|
||||
// rename in persist fails (renaming a file over a directory). This is
|
||||
// robust even when tests run as root (a chmod 0o500 dir would be
|
||||
// bypassed by root), and it exercises the invariant: on a write error
|
||||
// the in-memory doc is left unchanged (memory-before-persist fix).
|
||||
// the in-memory doc is left `unchanged` (memory-before-persist fix).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store_path = tmp.path().join(TRUST_FILE_NAME);
|
||||
std::fs::create_dir_all(&store_path).unwrap(); // store path is a dir, not a file
|
||||
// store path is a dir, not a file
|
||||
std::fs::create_dir_all(&store_path).unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
let key = canonicalize_or_owned(&repo);
|
||||
@@ -1416,7 +1420,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── workspace_key registry collapse (kigi-managed worktrees) ─────────
|
||||
// workspace_key registry collapse (kigi-managed worktrees)
|
||||
|
||||
// Crate-shared env lock + env guards bundled as ONE value so the env restores
|
||||
// before the lock releases by struct field order (see lib.rs), regardless of
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![allow(unexpected_cfgs)] // bundle_rg is set by the shell build script; harmless warning in the workspace lib
|
||||
// bundle_rg is set by the shell build script; harmless warning in the workspace lib
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -1604,7 +1604,7 @@ mod tests {
|
||||
/// destructuring below (no `..`) fails to compile when upstream adds or renames
|
||||
/// a field, and rebuilding `HookSpecWire` from those bindings catches wire-side
|
||||
/// drift; the assertion pins that both serde shapes stay byte-identical. The
|
||||
/// compiled `matcher` is `#[serde(skip)]` and is the only field intentionally
|
||||
/// compiled `matcher` is `#[serde(skip)]` and is the only field deliberately
|
||||
/// absent from the wire.
|
||||
#[test]
|
||||
fn hook_spec_wire_covers_all_upstream_fields() {
|
||||
|
||||
@@ -45,10 +45,8 @@ pub(crate) fn to_creation_mode(t: WorktreeType) -> kigi_fast_worktree::CreationM
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Btrfs delegate factory -- injected by binaries that link a concrete
|
||||
// snapshot helper delegate
|
||||
// ============================================================================
|
||||
|
||||
/// Process-global factory producing the btrfs delegate, if any.
|
||||
///
|
||||
@@ -87,9 +85,7 @@ fn get_head_commit(repo: &Repository) -> Result<String> {
|
||||
Ok(commit.id().to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// In-progress tracking
|
||||
// ============================================================================
|
||||
|
||||
// Process-local, best-effort dedup of duplicate async spawns within one process —
|
||||
// NOT a cross-process lock: in proxy mode `prepare` (hub) and creation (shell) are
|
||||
@@ -121,9 +117,7 @@ pub async fn mark_worktree_complete(session_id: &str) {
|
||||
worktree_registry().lock().await.remove(session_id);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Background Copy Infrastructure
|
||||
// ============================================================================
|
||||
|
||||
/// Default parallelism config for background tasks.
|
||||
/// This will leave some cores free in case foreground tasks are handled.
|
||||
@@ -346,9 +340,7 @@ pub async fn run_background_ignored_copy<N: WorktreeNotificationSender>(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request / Response types
|
||||
// ============================================================================
|
||||
|
||||
fn default_copy_mode() -> WorktreeCopyMode {
|
||||
WorktreeCopyMode::Dirty
|
||||
@@ -362,7 +354,7 @@ pub struct PrepareWorktreeResult {
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "status")]
|
||||
pub enum WorktreeStatus {
|
||||
// === EXISTING VARIANTS (unchanged for backward compatibility) ===
|
||||
// EXISTING VARIANTS (unchanged for backward compatibility)
|
||||
#[serde(rename = "progress")]
|
||||
Progress {
|
||||
#[serde(rename = "sessionId")]
|
||||
@@ -392,7 +384,7 @@ pub enum WorktreeStatus {
|
||||
message: String,
|
||||
},
|
||||
|
||||
// === NEW VARIANTS (additive -- old clients ignore unknown status values) ===
|
||||
// NEW VARIANTS (additive -- old clients ignore unknown status values)
|
||||
/// Emitted when analyzing the source worktree for dirty state
|
||||
#[serde(rename = "analyzing")]
|
||||
Analyzing {
|
||||
@@ -427,7 +419,7 @@ pub enum WorktreeStatus {
|
||||
current_file: Option<String>,
|
||||
},
|
||||
|
||||
// === BACKGROUND IGNORED FILE COPY VARIANTS ===
|
||||
// BACKGROUND IGNORED FILE COPY VARIANTS
|
||||
/// Background ignored file copy started
|
||||
#[serde(rename = "copyingIgnored")]
|
||||
CopyingIgnored {
|
||||
@@ -476,9 +468,7 @@ pub trait WorktreeNotificationSender {
|
||||
async fn send_worktree_status(&self, progress: WorktreeStatus);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Human-Readable Worktree Naming
|
||||
// ============================================================================
|
||||
|
||||
/// Maximum length for a sanitized label.
|
||||
pub const MAX_LABEL_LEN: usize = 64;
|
||||
@@ -614,9 +604,7 @@ pub fn resolve_label_collision(base_dir: &Path, label: &str) -> String {
|
||||
auto_label()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worktree Base Directory Resolution
|
||||
// ============================================================================
|
||||
|
||||
/// Resolve the kigi home for worktree paths via the **same** resolver used for
|
||||
/// `worktrees.db` (`kigi_fast_worktree::resolve_kigi_home`), so checkout dirs and
|
||||
@@ -761,9 +749,7 @@ pub fn touch_worktree_for_cwd(cwd: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worktree Lifecycle: Create
|
||||
// ============================================================================
|
||||
|
||||
pub async fn prepare_worktree_creation(req: &CreateWorktreeRequest) -> PrepareWorktreeResult {
|
||||
let source_path = Path::new(&req.source_path);
|
||||
@@ -1113,9 +1099,7 @@ pub async fn create_worktree_streaming<N: WorktreeNotificationSender>(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Remove Worktree
|
||||
// ============================================================================
|
||||
|
||||
pub async fn remove_worktree(
|
||||
req: &RemoveWorktreeRequest,
|
||||
@@ -1346,9 +1330,7 @@ async fn snapshot_and_remove_subagent_worktree(
|
||||
Ok(snapshot_ref)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Create Worktree from Existing Worktree (Fork Flow)
|
||||
// ============================================================================
|
||||
|
||||
/// Request to create a new worktree from an existing worktree.
|
||||
/// Used during session forking to create a copy of another worktree's state.
|
||||
@@ -1968,9 +1950,7 @@ pub async fn create_worktree_from_worktree_sync(
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Apply Worktree
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ApplyContext {
|
||||
@@ -2192,9 +2172,7 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Jujutsu workspace isolation
|
||||
// ============================================================================
|
||||
|
||||
use crate::session::git::{jj_cli, jj_cli_mut};
|
||||
|
||||
@@ -2291,9 +2269,7 @@ pub async fn remove_jj_workspace(workspace_path: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resume / Rehydrate types (types only -- impl stays in shell)
|
||||
// ============================================================================
|
||||
|
||||
/// Request to resume an existing session in a fresh worktree.
|
||||
///
|
||||
@@ -2376,9 +2352,7 @@ pub struct RehydrateSessionResponse {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Worktree Management / DB
|
||||
// ============================================================================
|
||||
|
||||
use kigi_fast_worktree::{
|
||||
DbStats, GcOptions, GcReport, ListFilter, WorktreeDb, WorktreeKind, WorktreeRecord,
|
||||
@@ -2466,9 +2440,7 @@ pub fn resolve_worktree_by_id_or_path(id_or_path: &str) -> Result<Option<std::pa
|
||||
if p.exists() { Ok(Some(p)) } else { Ok(None) }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Repo-wide candidate enumeration (for worktree resume)
|
||||
// ============================================================================
|
||||
|
||||
/// Build a deduplicated, deterministically-ordered list of candidate cwds
|
||||
/// for the same repository as `current_cwd`.
|
||||
@@ -2575,7 +2547,7 @@ mod tests {
|
||||
assert_eq!(cfg.agent_connect_timeout, Duration::from_secs(5));
|
||||
}
|
||||
|
||||
// ── snapshot_and_remove_subagent_worktree ────────────────────────────
|
||||
// snapshot_and_remove_subagent_worktree
|
||||
|
||||
/// Run a git command in `dir` and return trimmed stdout (test-only helper).
|
||||
fn git_out(dir: &Path, args: &[&str]) -> String {
|
||||
@@ -2751,7 +2723,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── worktree_record_for_cwd / touch_worktree_for_cwd ─────────────────
|
||||
// worktree_record_for_cwd / touch_worktree_for_cwd
|
||||
|
||||
// Crate-shared env lock + env guards bundled as ONE value so the env
|
||||
// restores before the lock releases by struct field order (see lib.rs),
|
||||
|
||||
Reference in New Issue
Block a user