fix(trust): gate directory-typed project hook sources

Port of upstream grok-build 7581004 (1.0.22 window) folder_trust.rs.

`collect_repo_config_kinds` gated `.kigi/hooks` with `is_dir()` and
`.cursor/hooks.json` with `is_file()`, and `.claude/settings*.json` via
the settings-file scan, while the hook loader (`path_to_source`) takes
a directory at any of the four project hook paths as a hook source and
parses its JSON children. A clone carrying `.cursor/hooks.json/evil.json`
or `.claude/settings.json/evil.json` and nothing else resolved as
trusted through the no-config branch and ran its SessionStart hook.

The gate now treats any present path (file, directory, dangling
symlink) or an unreadable one at the four sources
`discover_hook_source_paths` loads from the git root as a hit.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
雷电芽衣 committed 2026-09-13 02:17:25 -04:00
1 parent 97c934a647
commit 7a60cd42ac
1 file changed
+63 -2
@@ -240,6 +240,16 @@ pub fn persist_trust(store: &mut TrustStore, key: &Path) {
/// the gate and the display-only [`repo_config_kinds`] enumerate the EXACT same
/// markers (they cannot drift) while this hot path still short-circuits on the
/// first hit.
/// Present in any form (file, directory, dangling symlink) or unreadable.
/// The hook loader takes a directory at either path as a hook source, so
/// only a clean NotFound clears the gate.
fn path_present_or_uncertain(path: &Path) -> bool {
match std::fs::symlink_metadata(path) {
Ok(_) => true,
Err(err) => err.kind() != std::io::ErrorKind::NotFound,
}
}
pub fn repo_configs_present(cwd: &Path) -> bool {
!collect_repo_config_kinds(cwd, true).is_empty()
}
@@ -345,9 +355,17 @@ fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str>
// must be gated — else a hooks-only clone (e.g. `.kigi/hooks/evil.json`) would
// resolve trusted and run ungated. Presence mirrors discovery's "something to
// gate" check.
// The four project hook sources `discover_hook_source_paths` loads from
// the git root; a directory at any of them is taken as a hook source.
let hook_root = chain.git_root.as_deref().unwrap_or(cwd);
if hook_root.join(".kigi").join("hooks").is_dir()
|| hook_root.join(".cursor").join("hooks.json").is_file()
if [
hook_root.join(".kigi").join("hooks"),
hook_root.join(".cursor").join("hooks.json"),
hook_root.join(".claude").join("settings.json"),
hook_root.join(".claude").join("settings.local.json"),
]
.iter()
.any(|path| path_present_or_uncertain(path))
{
hit!("hooks");
}
@@ -610,6 +628,49 @@ mod tests {
assert!(repo_configs_present(tmp.path()));
}
#[test]
fn repo_configs_present_detects_cursor_hooks_json_directory() {
// The loader takes a directory here as a hook source; the gate must
// not clear it just because it is not a regular file.
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".cursor").join("hooks.json")).unwrap();
assert!(repo_configs_present(tmp.path()));
assert!(repo_config_kinds(tmp.path()).contains(&"hooks"));
}
#[test]
fn repo_configs_present_detects_claude_settings_directories() {
for name in ["settings.json", "settings.local.json"] {
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".claude").join(name)).unwrap();
assert!(repo_configs_present(tmp.path()), "{name}");
assert!(repo_config_kinds(tmp.path()).contains(&"hooks"), "{name}");
}
}
#[test]
fn repo_configs_present_detects_project_hooks_file() {
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".kigi")).unwrap();
std::fs::write(tmp.path().join(".kigi").join("hooks"), "{}").unwrap();
assert!(repo_configs_present(tmp.path()));
assert!(repo_config_kinds(tmp.path()).contains(&"hooks"));
}
#[cfg(unix)]
#[test]
fn repo_configs_present_detects_dangling_cursor_hooks_symlink() {
let tmp = repo_tmp();
std::fs::create_dir_all(tmp.path().join(".cursor")).unwrap();
std::os::unix::fs::symlink(
tmp.path().join("missing-target"),
tmp.path().join(".cursor").join("hooks.json"),
)
.unwrap();
assert!(repo_configs_present(tmp.path()));
assert!(repo_config_kinds(tmp.path()).contains(&"hooks"));
}
#[test]
fn repo_configs_present_detects_project_hooks_from_subdir() {
// Hooks live at the git root but the session is launched from a subdir;