fix(config): a symlinked ~/.kigi/config.toml survives a save

Every config.toml writer wrote a tmp beside the path and renamed it over
the path. With config.toml symlinked into a dotfiles repo, the rename
replaced the link with a regular file: the repo copy went stale and the
link was gone.

`config_write_dest` resolves the destination before a writer names its
tmp. For the user's own config path a symlink resolves to its referent,
so the tmp sits on the referent's filesystem and the rename stays atomic.
A dangling link is an error, not a clobber. All eight writers call it:
`save_config`, the four MCP writers, and the three import writers.

Only the user's config path is followed, and only while
`user_kigi_home()` resolves. `kigi mcp add --scope project` and the
Claude project import write `.kigi/config.toml` inside the repo; a
hostile repo could ship that file as a symlink to any file the user
owns. With no home, `kigi_home()` falls back to `./.kigi`, which is that
same project tree, so nothing is followed then. Untrusted paths come
back unchanged and the link itself is replaced, as before. AGENTS.md
records the rule.

Tests (unix; run on Linux under WSL): helper cases including the no-home
case, a project-scope save that spares the link's target, and an
end-to-end save under KIGI_SHARE_DIR. The end-to-end test fails with
following disabled. Windows symlinks were not exercised: the host lacks
the privilege.

Ported from upstream (grok-build config/fs_atomic.rs LeafSymlink::Follow,
1.0.35): the rule, not the 1375-line module.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-19 03:15:01 -04:00
1 parent 206711bed5
commit 0189009822
8 files changed
+164

No files matched your search

@@ -513,6 +513,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
if applied.total_added() > 0 {
// Atomic write: tmp + rename (same pattern as save_mcp_server_config).
let toml_str = toml::to_string_pretty(&root)?;
let config_path = crate::util::config::config_write_dest(&config_path)?;
let tmp = config_path.with_extension("toml.tmp");
if let Err(e) = std::fs::write(&tmp, &toml_str) {
let _ = std::fs::remove_file(&tmp);