feat(TASK-039): wire hooks into bundle installs and reconcile builtin hook dirs

Bundles: RemoteLayout.hooks + scan/is_empty/plan routing into hooks_dir,
a functional --filter hooks arm replacing the inert interim arm,
TopCategory::Hooks provenance rows, and per-file 'bundle defines hook
script' summary lines (visibility only, no approval prompt).

Reconciliation: new builtin_manifest module — a hidden per-directory
.builtin-manifest records the files the builtin installer actually owns
(written this run, previously owned, or pending failed deletions);
deletion is strictly manifest-minus-embed so user-created files in the
same directory are never touched. Wired into install_builtin_agents for
agents/<name>/hooks/ and a greenfield minimal Role::install_builtin_role_hooks
for roles/hooks/.

Hardening: bundles are barred from shipping any path component that
aliases .builtin-manifest (Unicode case fold + trailing dot/space,
directories included); manifest entries reuse the bundle store's
is_safe_component rules; malformed manifests fail safe toward keeping
files; role-side reconcile failures degrade to a warning like the
agent side.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:44:35 -06:00
1 parent e62502c862
commit 42380d4b82
7 files changed
+893 -8

No files matched your search

+285 -8
View File
@@ -2,6 +2,7 @@ use super::bundles::{
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes,
hash_file,
};
use crate::config::builtin_manifest::is_builtin_manifest_name;
use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths};
#[cfg(not(windows))]
use crate::function::Language;
@@ -32,7 +33,7 @@ pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool)
if layout.is_empty() {
println!(
"No recognized assets found in {git_url}. Expected one or more of: \
agents/, roles/, skills/, macros/, functions/tools/, mcp.json"
agents/, roles/, skills/, macros/, functions/tools/, hooks/, mcp.json"
);
return Ok(());
}
@@ -805,7 +806,7 @@ fn is_safe_relative_path(path: &str) -> bool {
/// Rejects names Windows refuses or silently rewrites (alternate data stream
/// colons, reserved device names, trailing dots or spaces) so a recorded path
/// denotes the same regular file on every platform.
fn is_safe_component(name: &str) -> bool {
pub(crate) fn is_safe_component(name: &str) -> bool {
!name.contains(':')
&& !name.ends_with('.')
&& !name.ends_with(' ')
@@ -1270,6 +1271,7 @@ struct RemoteLayout {
roles: Option<PathBuf>,
skills: Option<PathBuf>,
macros: Option<PathBuf>,
hooks: Option<PathBuf>,
functions_tools: Option<PathBuf>,
mcp_json: Option<PathBuf>,
manifest: Option<BundleManifest>,
@@ -1282,6 +1284,7 @@ impl RemoteLayout {
&& self.roles.is_none()
&& self.skills.is_none()
&& self.macros.is_none()
&& self.hooks.is_none()
&& self.functions_tools.is_none()
&& self.mcp_json.is_none()
}
@@ -1313,6 +1316,11 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
layout.macros = Some(macros);
}
let hooks = root.join("hooks");
if hooks.is_dir() {
layout.hooks = Some(hooks);
}
let root_mcp = root.join("mcp.json");
if root_mcp.is_file() {
layout.mcp_json = Some(root_mcp);
@@ -1540,10 +1548,10 @@ fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> Remo
functions_tools: layout.functions_tools.take(),
..base
},
// Bundles cannot ship hooks yet: RemoteLayout has no hooks field, so
// this filter always yields an empty layout. Bundle-side hooks support
// (layout field, scan, is_empty) lands together with TopCategory::Hooks.
InstallFilter::Hooks => base,
InstallFilter::Hooks => RemoteLayout {
hooks: layout.hooks.take(),
..base
},
InstallFilter::McpConfig => RemoteLayout {
mcp_json: layout.mcp_json.take(),
..base
@@ -1595,6 +1603,7 @@ enum TopCategory {
Roles,
Skills,
Macros,
Hooks,
FunctionsTools,
}
@@ -1605,6 +1614,7 @@ impl TopCategory {
TopCategory::Roles => "roles",
TopCategory::Skills => "skills",
TopCategory::Macros => "macros",
TopCategory::Hooks => "hooks",
TopCategory::FunctionsTools => "functions/tools",
}
}
@@ -1623,6 +1633,8 @@ enum PlannedKind {
struct PlannedFile {
src: PathBuf,
dst: PathBuf,
/// Path relative to the category's source directory in the bundle.
rel: PathBuf,
kind: PlannedKind,
top_category: TopCategory,
}
@@ -1664,6 +1676,9 @@ fn plan_changes(layout: &RemoteLayout) -> Result<InstallPlan> {
&mut files,
)?;
}
if let Some(src_dir) = &layout.hooks {
plan_dir_into(src_dir, &paths::hooks_dir(), TopCategory::Hooks, &mut files)?;
}
if let Some(src_dir) = &layout.functions_tools {
plan_dir_into(
src_dir,
@@ -1690,7 +1705,28 @@ fn plan_dir_into(
for src in walk_files(src_dir)? {
let rel = src
.strip_prefix(src_dir)
.expect("walk_files only returns paths under src_dir");
.expect("walk_files only returns paths under src_dir")
.to_path_buf();
// The builtin installer's per-directory manifests drive stale-file
// deletion; letting a bundle install one would hand it control over
// which local files the next builtin reconcile removes. Every path
// component is checked (a bundle-shipped DIRECTORY of that name would
// permanently occupy the manifest path and disable reconciliation),
// under the filesystem-alias-aware match of is_builtin_manifest_name.
let is_builtin_manifest = rel.components().any(|c| match c.as_os_str().to_str() {
Some(name) => is_builtin_manifest_name(name),
// A non-UTF-8 name can never equal the ASCII manifest constant.
None => false,
});
if is_builtin_manifest {
log::warn!(
"Ignoring bundle file {} ({}): the name is reserved for the builtin installer",
rel.display(),
category.label()
);
continue;
}
if category == TopCategory::Skills {
let skill_name = rel
@@ -1711,11 +1747,12 @@ fn plan_dir_into(
})?;
}
let dst = dst_dir.join(rel);
let dst = dst_dir.join(&rel);
let kind = classify_file(&src, &dst)?;
out.push(PlannedFile {
src,
dst,
rel,
kind,
top_category: category,
});
@@ -1794,6 +1831,7 @@ fn print_plan_summary(plan: &InstallPlan) {
TopCategory::Roles,
TopCategory::Skills,
TopCategory::Macros,
TopCategory::Hooks,
TopCategory::FunctionsTools,
] {
let new_ = count_kind(plan, cat, PlannedKind::New);
@@ -1812,6 +1850,33 @@ fn print_plan_summary(plan: &InstallPlan) {
println!("{line}");
}
}
for line in hook_script_lines(plan) {
println!("{line}");
}
}
/// Bundle-shipped hook scripts execute on the user's machine once wired into
/// config, so every planned file landing under a hooks/ directory is called
/// out individually. Visibility only: installing them needs no extra approval.
fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
plan.files
.iter()
.filter(|planned| {
planned.top_category == TopCategory::Hooks
|| planned
.rel
.parent()
.is_some_and(|parent| parent.components().any(|c| c.as_os_str() == "hooks"))
})
.map(|planned| {
let rel = planned.rel.to_string_lossy().replace('\\', "/");
format!(
"bundle defines hook script: {}/{rel}",
planned.top_category.label()
)
})
.collect()
}
fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize {
@@ -2353,6 +2418,7 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::utils::get_env_name;
use serial_test::serial;
@@ -2555,6 +2621,7 @@ mod tests {
roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
macros: Some(PathBuf::from("m")),
hooks: Some(PathBuf::from("h")),
functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
@@ -2564,6 +2631,7 @@ mod tests {
assert!(out.agents.is_some() && out.roles.is_some() && out.skills.is_some());
assert!(out.macros.is_some() && out.functions_tools.is_some() && out.mcp_json.is_some());
assert!(out.hooks.is_some());
}
#[test]
@@ -2642,6 +2710,39 @@ mod tests {
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
}
/// `--filter hooks` used to be an inert arm that always produced an empty
/// layout; it now selects the bundle's top-level hooks directory.
#[test]
fn apply_filter_hooks_keeps_only_hooks() {
let l = RemoteLayout {
agents: Some(PathBuf::from("a")),
roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
macros: Some(PathBuf::from("m")),
hooks: Some(PathBuf::from("h")),
functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
};
let out = apply_filter(l, Some(InstallFilter::Hooks));
assert_eq!(out.hooks, Some(PathBuf::from("h")));
assert!(!out.is_empty());
assert!(out.agents.is_none() && out.roles.is_none() && out.skills.is_none());
assert!(out.macros.is_none() && out.functions_tools.is_none() && out.mcp_json.is_none());
}
#[test]
fn remote_layout_with_only_hooks_is_not_empty() {
let l = RemoteLayout {
hooks: Some(PathBuf::from("h")),
..RemoteLayout::default()
};
assert!(!l.is_empty());
}
#[test]
fn walk_files_skips_dot_git_and_collects_regular_files() {
let root = fresh_temp_dir("walk-test-");
@@ -2688,6 +2789,7 @@ mod tests {
fs::create_dir_all(root.join("roles")).unwrap();
fs::create_dir_all(root.join("skills")).unwrap();
fs::create_dir_all(root.join("macros")).unwrap();
fs::create_dir_all(root.join("hooks")).unwrap();
fs::create_dir_all(root.join("functions/tools")).unwrap();
touch(&root.join("functions/mcp.json"));
touch(&root.join("README.md"));
@@ -2697,11 +2799,29 @@ mod tests {
assert!(layout.roles.is_some());
assert!(layout.skills.is_some());
assert!(layout.macros.is_some());
assert!(layout.hooks.is_some());
assert!(layout.functions_tools.is_some());
assert!(layout.mcp_json.is_some());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_finds_hooks_only() {
let root = fresh_temp_dir("scan-hooks-only-");
fs::create_dir_all(root.join("hooks")).unwrap();
touch(&root.join("hooks/notify.sh"));
let layout = scan_remote_layout(&root).unwrap();
assert_eq!(layout.hooks, Some(root.join("hooks")));
assert!(layout.agents.is_none());
assert!(layout.roles.is_none());
assert!(layout.macros.is_none());
assert!(layout.functions_tools.is_none());
assert!(layout.mcp_json.is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_finds_root_mcp_json() {
let root = fresh_temp_dir("scan-root-mcp-test-");
@@ -2756,6 +2876,71 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn plan_dir_into_skips_bundle_shipped_builtin_manifests() {
let root = fresh_temp_dir("plan-manifest-skip-");
let src_dir = root.join("hooks");
touch(&src_dir.join("notify.sh"));
touch(&src_dir.join(BUILTIN_MANIFEST_FILE));
touch(&src_dir.join(format!("sub/{BUILTIN_MANIFEST_FILE}")));
// Case and trailing-dot variants alias the real manifest path on
// case-insensitive filesystems; they live in separate subdirs so this
// fixture also builds on such filesystems without name collisions.
touch(&src_dir.join("sub2/.BUILTIN-MANIFEST"));
touch(&src_dir.join("sub3/.Builtin-Manifest."));
// A DIRECTORY of the manifest name would occupy the manifest path and
// disable reconciliation; a Unicode case-fold alias (U+017F ſ → s)
// aliases it on case-insensitive APFS/NTFS volumes.
touch(&src_dir.join("sub4/.builtin-manifest/payload.sh"));
touch(&src_dir.join("sub5/.builtin-manifeſt"));
let dst_dir = root.join("dst");
let mut files = Vec::new();
plan_dir_into(&src_dir, &dst_dir, TopCategory::Hooks, &mut files).unwrap();
let rels: Vec<&Path> = files.iter().map(|p| p.rel.as_path()).collect();
assert_eq!(
rels,
vec![Path::new("notify.sh")],
"a bundle must never install a builtin manifest: it would hand the \
bundle control over builtin stale-file deletion"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn hook_script_lines_cover_every_hooks_dir_and_nothing_else() {
let file = |rel: &str, cat: TopCategory| PlannedFile {
src: PathBuf::from("src").join(rel),
dst: PathBuf::from("dst").join(rel),
rel: PathBuf::from(rel),
kind: PlannedKind::New,
top_category: cat,
};
let plan = InstallPlan {
files: vec![
file("notify.sh", TopCategory::Hooks),
file("x/hooks/pre.sh", TopCategory::Agents),
file("hooks/role-hook.sh", TopCategory::Roles),
file("x/config.yaml", TopCategory::Agents),
file("reviewer.md", TopCategory::Roles),
file("hello.yaml", TopCategory::Macros),
],
mcp_json: None,
};
let lines = hook_script_lines(&plan);
assert_eq!(
lines,
vec![
"bundle defines hook script: hooks/notify.sh",
"bundle defines hook script: agents/x/hooks/pre.sh",
"bundle defines hook script: roles/hooks/role-hook.sh",
]
);
}
#[test]
fn classify_file_new_when_dst_missing() {
let dir = fresh_temp_dir("classify-new-");
@@ -3424,6 +3609,90 @@ mod tests {
let _ = fs::remove_dir_all(&src_root);
}
fn hooks_fixture_bundle(label: &str) -> (PathBuf, PathBuf) {
let src_root = fresh_temp_dir(label);
let repo = src_root.join("hooky");
write_src(&repo, "hooks/notify-me.sh", "#!/bin/sh\necho hi\n");
write_src(&repo, "roles/hooks/role-hook.sh", "#!/bin/sh\n");
write_src(&repo, "agents/x/hooks/pre.sh", "#!/bin/sh\n");
write_src(&repo, "macros/hello.yaml", "name: hello\n");
init_bundle_repo(&repo);
(src_root, repo)
}
#[cfg(unix)]
fn assert_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path).unwrap().permissions().mode();
assert!(
mode & 0o111 != 0,
"{} must carry the executable bit",
path.display()
);
}
#[test]
#[serial]
fn install_remote_installs_all_hooks_dirs_and_records_hooks_category() {
let _guard = TestVaultConfigGuard::new("prov-hooks-dirs");
let (src_root, repo) = hooks_fixture_bundle("prov-hooks-dirs-src-");
install_remote(repo.to_str().unwrap(), None, false).unwrap();
let top = paths::hooks_dir().join("notify-me.sh");
let role = paths::roles_dir().join("hooks/role-hook.sh");
let agent = paths::agents_data_dir().join("x/hooks/pre.sh");
assert!(top.is_file(), "top-level hooks/ installs to hooks_dir");
assert!(role.is_file(), "roles/hooks/ installs via the roles walk");
assert!(
agent.is_file(),
"agents/<name>/hooks/ installs via the agents walk"
);
#[cfg(unix)]
{
assert_executable(&top);
assert_executable(&role);
assert_executable(&agent);
}
let store = BundleStore::load().unwrap();
let record = store.get("hooky").unwrap();
let category_of = |path: &str| {
record
.files
.iter()
.find(|f| f.path == path)
.unwrap_or_else(|| panic!("no provenance row for {path}"))
.category
.clone()
};
assert_eq!(category_of("hooks/notify-me.sh"), "hooks");
assert_eq!(category_of("roles/hooks/role-hook.sh"), "roles");
assert_eq!(category_of("agents/x/hooks/pre.sh"), "agents");
let _ = fs::remove_dir_all(&src_root);
}
#[test]
#[serial]
fn install_remote_filter_hooks_restricts_to_top_level_hooks_dir() {
let _guard = TestVaultConfigGuard::new("prov-hooks-filter");
let (src_root, repo) = hooks_fixture_bundle("prov-hooks-filter-src-");
install_remote(repo.to_str().unwrap(), Some(InstallFilter::Hooks), false).unwrap();
assert!(paths::hooks_dir().join("notify-me.sh").is_file());
assert!(!paths::roles_dir().join("hooks/role-hook.sh").exists());
assert!(!paths::agents_data_dir().join("x/hooks/pre.sh").exists());
assert!(!paths::macros_dir().join("hello.yaml").exists());
let store = BundleStore::load().unwrap();
let record = store.get("hooky").unwrap();
assert_eq!(record.files.len(), 1);
assert_eq!(record.files[0].path, "hooks/notify-me.sh");
assert_eq!(record.files[0].category, "hooks");
let _ = fs::remove_dir_all(&src_root);
}
#[test]
#[serial]
fn install_remote_without_manifest_uses_repo_slug_and_short_sha_version() {
@@ -3471,12 +3740,14 @@ mod tests {
PlannedFile {
src: src_new,
dst: dst_new.clone(),
rel: PathBuf::from("new.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Macros,
},
PlannedFile {
src: src_conflict,
dst: dst_conflict.clone(),
rel: PathBuf::from("conflict.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
},
@@ -3529,6 +3800,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3840,6 +4112,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3886,6 +4159,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("owned.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -3965,6 +4239,7 @@ mod tests {
files: vec![PlannedFile {
src,
dst: dst.clone(),
rel: PathBuf::from("shared.yaml"),
kind: PlannedKind::Conflict,
top_category: TopCategory::Macros,
}],
@@ -4237,6 +4512,7 @@ mod tests {
files: vec![PlannedFile {
src: dir.join("bundle-src/agents/foo/graph.yaml"),
dst: dir.join("agents/foo/graph.yaml"),
rel: PathBuf::from("foo/graph.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Agents,
}],
@@ -4279,6 +4555,7 @@ mod tests {
files: vec![PlannedFile {
src: dir.join("bundle-src/agents/foo/graph.yaml"),
dst: dir.join("agents/foo/graph.yaml"),
rel: PathBuf::from("foo/graph.yaml"),
kind: PlannedKind::New,
top_category: TopCategory::Agents,
}],