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:
16 files changed
+627
-353
No files matched your search
@@ -4,9 +4,9 @@ use super::bundles::{
|
||||
};
|
||||
use crate::config::builtin_manifest::is_builtin_manifest_name;
|
||||
use crate::config::conflict::{ConflictAction, NonInteractive, StickyMode, resolve_conflict};
|
||||
use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths};
|
||||
#[cfg(not(windows))]
|
||||
use crate::function::Language;
|
||||
use crate::config::{
|
||||
AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths, set_executable_bit_if_script,
|
||||
};
|
||||
use crate::mcp::{McpServer, McpServersConfig};
|
||||
use crate::utils;
|
||||
use crate::utils::IS_STDOUT_TERMINAL;
|
||||
@@ -1858,18 +1858,18 @@ fn print_plan_summary(plan: &InstallPlan) {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// config, so every planned file landing in a hook location is called out
|
||||
/// individually. Visibility only: installing them needs no extra approval.
|
||||
///
|
||||
/// Only the locations the engine actually resolves hooks from are announced:
|
||||
/// the top-level hooks category, direct children of `agents/<name>/hooks/`,
|
||||
/// and direct children of `roles/hooks/`. A `hooks/` directory anywhere else
|
||||
/// (e.g. inside a skill or macro subtree) is inert data and announcing it
|
||||
/// would be noise.
|
||||
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"))
|
||||
})
|
||||
.filter(|planned| is_hook_location(planned))
|
||||
.map(|planned| {
|
||||
let rel = planned.rel.to_string_lossy().replace('\\', "/");
|
||||
format!(
|
||||
@@ -1880,6 +1880,16 @@ fn hook_script_lines(plan: &InstallPlan) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_hook_location(planned: &PlannedFile) -> bool {
|
||||
let components: Vec<_> = planned.rel.components().collect();
|
||||
match planned.top_category {
|
||||
TopCategory::Hooks => true,
|
||||
TopCategory::Agents => components.len() == 3 && components[1].as_os_str() == "hooks",
|
||||
TopCategory::Roles => components.len() == 2 && components[0].as_os_str() == "hooks",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize {
|
||||
plan.files
|
||||
.iter()
|
||||
@@ -2042,26 +2052,6 @@ fn write_file(src: &Path, dst: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_executable_bit_if_script(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let Some(ext) = path.extension().and_then(OsStr::to_str) else {
|
||||
return Ok(());
|
||||
};
|
||||
if Language::from_extension(ext) == Language::Unsupported {
|
||||
return Ok(());
|
||||
}
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
||||
.with_context(|| format!("chmod {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_executable_bit_if_script(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct McpMergeReport {
|
||||
added: Vec<String>,
|
||||
@@ -2874,6 +2864,14 @@ mod tests {
|
||||
file("x/config.yaml", TopCategory::Agents),
|
||||
file("reviewer.md", TopCategory::Roles),
|
||||
file("hello.yaml", TopCategory::Macros),
|
||||
// Only the locations the engine resolves hooks from announce:
|
||||
// a hooks/ directory nested deeper or under another category
|
||||
// is inert data.
|
||||
file("x/hooks/nested/deep.sh", TopCategory::Agents),
|
||||
file("x/sub/hooks/pre.sh", TopCategory::Agents),
|
||||
file("sub/hooks/extra.sh", TopCategory::Roles),
|
||||
file("my-skill/hooks/tool.sh", TopCategory::Skills),
|
||||
file("my-macro/hooks/tool.sh", TopCategory::Macros),
|
||||
],
|
||||
mcp_json: None,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user