feat(TASK-048): install polish, hooks-dir mixin scan, script and probe hardening

- one shared exec-bit predicate (Language-recognized extensions => 0755) used by
  every hook/agent installer; non-script files in hooks dirs stay non-executable
- mixin discovery scans the global hooks dir and each agent's hooks dir;
  .builtin-manifest is never scanned
- NTFS 8.3 short-name aliases rejected as builtin-manifest entries
- hook-script install announcements restricted to real hook locations
- COYOTE_HOOKS_DIR override warning compares canonicalized paths
- embedded assets decode via checked from_utf8 instead of from_utf8_unchecked
- log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log
  keeping 0600/symlink-refusal/secret-filter hardening; notify.sh tty fallback strips
  control bytes; both use UTC ISO-8601 timestamps
- payload files orphaned by a write failure are unlinked; stale coyote-hook-*.json
  payloads swept best-effort at startup
- sweep_removed_agent_hooks never follows symlinked agent dirs
- usage-probe-hooks.sh replaces bare sleeps with a bounded poll, re-verifies 0755 on
  restored scripts, and covers roles/agent hook locations
- TestConfigDirGuard hoisted into crate::testing
- .gitattributes pins LF line endings for bundled scripts

hooks is now a reserved bundle name.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 18:30:02 -06:00
1 parent e7bd75947c
commit 89a9398c28
16 files changed
+627 -353

No files matched your search

+58
View File
@@ -197,6 +197,16 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
) {
out.push(read_mixin(path)?);
}
// Hooks directories are deliberately scanned: hook scripts run inside the
// sandbox too, so a sidecar mixin next to one can declare its needs. The
// agents scan above never descends into `agents/<name>/hooks/`, and the
// `.builtin-manifest` living in these dirs is not matched by the
// `*.sbx-mixin.yaml` suffix filter.
for agent_dir in subdirs_of(&paths::agents_data_dir()) {
for path in collect_mixins(&agent_dir.join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
}
for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
@@ -206,6 +216,9 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
for path in collect_mixins(&paths::hooks_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
if let Ok(cwd) = env::current_dir()
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
@@ -1005,6 +1018,51 @@ network:
let _ = fs::remove_dir_all(&root);
}
#[test]
#[serial_test::serial]
fn discover_scans_global_and_agent_hooks_mixins_but_never_the_manifest() {
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
use crate::utils::get_env_name;
let root = unique_root("discover-hooks-dirs");
let hooks = root.join("hooks");
let agent_hooks = root.join("agents").join("researcher").join("hooks");
fs::create_dir_all(&hooks).unwrap();
fs::create_dir_all(&agent_hooks).unwrap();
let global_mixin = hooks.join("notify.sbx-mixin.yaml");
let agent_mixin = agent_hooks.join("guard.sbx-mixin.yaml");
fs::write(&global_mixin, "kind: mixin\n").unwrap();
fs::write(&agent_mixin, "kind: mixin\n").unwrap();
// The builtin installer's hidden manifest lives in both hooks dirs
// but is not a mixin and must never be picked up.
fs::write(hooks.join(BUILTIN_MANIFEST_FILE), "notify.sh\n").unwrap();
fs::write(agent_hooks.join(BUILTIN_MANIFEST_FILE), "guard.sh\n").unwrap();
let _config = crate::testing::EnvVarGuard::set(get_env_name("config_dir"), &root);
let found: Vec<PathBuf> = discover()
.unwrap()
.into_iter()
.map(|mixin| mixin.path)
.collect();
assert!(
found.contains(&global_mixin),
"missing global hooks mixin: {found:?}"
);
assert!(
found.contains(&agent_mixin),
"missing agent hooks mixin: {found:?}"
);
assert!(
found.iter().all(|path| path
.file_name()
.is_none_or(|name| name != BUILTIN_MANIFEST_FILE)),
"builtin manifest must not be scanned as a mixin: {found:?}"
);
let _ = fs::remove_dir_all(&root);
}
/// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
/// existed, nothing scanned that shape and attaching a Qdrant RAG from