From 1ece9388cd6d621475384d005b639a875de7a2e7 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5" Date: Sat, 17 Oct 2026 13:12:48 +0000 Subject: [PATCH] feat(managed): session hooks for Claude Code, Codex, Cursor and Pi Managed apply now registers its session-start hook for exactly four agents: Claude Code, Codex CLI, Cursor and Pi. Every other agent, Gemini CLI included, is reached by the timer alone, and apply removes a FastSkill hook still registered for one of them. - Hooks are built from argv; aikit derives the shell command. - Approval notes print for a hook that is added or changed. - An agent file FastSkill can't change is a warning, not an error. - `managed hooks --system` prints TOML for Codex and says Pi has no administrator-level location; `--json` carries agent, file, format, text and present. - aikit moves to f91c37f and cli-framework to 388e97b. Implements specs/managed-session-hooks-prd.md and accepts the third amendment of ADR-0016. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG | 13 +- Cargo.lock | 18 +-- Cargo.toml | 8 +- crates/fastskill-cli/src/commands/managed.rs | 2 +- .../src/commands/managed_hooks.rs | 37 +++-- .../src/commands/managed_hooks_tests.rs | 68 +++++++- .../fastskill-core/src/core/managed/hooks.rs | 90 +++++++---- .../src/core/managed/hooks_tests.rs | 147 +++++++++++++++--- ...-machines-follow-a-signed-managed-state.md | 11 +- specs/managed-session-hooks-prd.md | 5 +- webdocs/cli-reference/managed-command.mdx | 35 +++-- webdocs/security/managed-machines.mdx | 37 +++-- 12 files changed, 351 insertions(+), 120 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 30567d69..eb3b2808 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -80,12 +80,21 @@ replaces skill content is checked by digest first; `skill list` reports `managed-blocked` and `managed-not-allowed`, and `--check` fails on them. An `https://` source can name a credential command for sign-in, receive a fixed, inspectable report after each apply, honor `editable` and - point refusals at a `request_url`. Session-start hooks in Claude Code and Gemini CLI and a - per-user timer (at login, then hourly) keep machines up to date, and `managed hooks --system` + point refusals at a `request_url`. Session-start hooks in Claude Code, Codex CLI, Cursor and Pi + and a per-user timer (at login, then hourly) keep machines up to date, and `managed hooks --system` prints what an administrator installs machine-wide. Nothing changes until a source is configured. See [ADR-0016](docs/adr/0016-machines-follow-a-signed-managed-state.md), the [managed state](webdocs/skill-management/managed-state.mdx) format and [Hardening managed machines](webdocs/security/managed-machines.mdx). +- **Managed session hooks cover Claude Code, Codex CLI, Cursor and Pi.** Every other agent, + Gemini CLI included, is kept up to date by the timer alone, and the next apply removes a + FastSkill hook left in one of them. Enroll and apply print the approval step for a hook that is + new or changed (Claude Code's `/hooks` review, Codex's `/hooks` trust); a hook file FastSkill + can't change, such as a Codex `config.toml` that already has a `hooks` table or a Pi extension + file FastSkill didn't write, is a warning, not an error. Each agent gets the sign-in notice in + its own form. `managed hooks --system` prints a TOML entry for Codex's `requirements.toml`, and + says Pi has no administrator-level location. See the third amendment of + [ADR-0016](docs/adr/0016-machines-follow-a-signed-managed-state.md). - **`storage::git_isolated::clone_isolated` for cloning repositories you do not control.** The library clone reads no system, global or user git configuration and inherits no environment beyond `PATH`, proxy and CA settings, so a host's credential helper, `insteadOf` rewrite or diff --git a/Cargo.lock b/Cargo.lock index b8a09cbd..7829dd33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,7 +34,7 @@ dependencies = [ [[package]] name = "aikit-agent" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=f9c8d2786bc8da2e2d34daf362e6a2a59500502d#f9c8d2786bc8da2e2d34daf362e6a2a59500502d" +source = "git+https://github.com/goaikit/aikit.git?rev=f91c37f08749b47890bc093e9e0fb8373c8b00ed#f91c37f08749b47890bc093e9e0fb8373c8b00ed" dependencies = [ "reqwest 0.12.28", "serde", @@ -47,7 +47,7 @@ dependencies = [ [[package]] name = "aikit-evals" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=f9c8d2786bc8da2e2d34daf362e6a2a59500502d#f9c8d2786bc8da2e2d34daf362e6a2a59500502d" +source = "git+https://github.com/goaikit/aikit.git?rev=f91c37f08749b47890bc093e9e0fb8373c8b00ed#f91c37f08749b47890bc093e9e0fb8373c8b00ed" dependencies = [ "aikit-sdk", "async-trait", @@ -68,7 +68,7 @@ dependencies = [ [[package]] name = "aikit-sdk" version = "0.3.0" -source = "git+https://github.com/goaikit/aikit.git?rev=f9c8d2786bc8da2e2d34daf362e6a2a59500502d#f9c8d2786bc8da2e2d34daf362e6a2a59500502d" +source = "git+https://github.com/goaikit/aikit.git?rev=f91c37f08749b47890bc093e9e0fb8373c8b00ed#f91c37f08749b47890bc093e9e0fb8373c8b00ed" dependencies = [ "aikit-agent", "claude-agent-sdk", @@ -92,7 +92,7 @@ dependencies = [ [[package]] name = "aikit-skillopt" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=f9c8d2786bc8da2e2d34daf362e6a2a59500502d#f9c8d2786bc8da2e2d34daf362e6a2a59500502d" +source = "git+https://github.com/goaikit/aikit.git?rev=f91c37f08749b47890bc093e9e0fb8373c8b00ed#f91c37f08749b47890bc093e9e0fb8373c8b00ed" dependencies = [ "aikit-evals", "aikit-sdk", @@ -105,7 +105,7 @@ dependencies = [ [[package]] name = "aikit-textgrad" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=f9c8d2786bc8da2e2d34daf362e6a2a59500502d#f9c8d2786bc8da2e2d34daf362e6a2a59500502d" +source = "git+https://github.com/goaikit/aikit.git?rev=f91c37f08749b47890bc093e9e0fb8373c8b00ed#f91c37f08749b47890bc093e9e0fb8373c8b00ed" dependencies = [ "aikit-evals", "aikit-sdk", @@ -645,7 +645,7 @@ dependencies = [ [[package]] name = "cli-framework" version = "0.5.8" -source = "git+https://github.com/aroff/cli-framework?rev=86c22b28cd4a36474d194b44b0bc6fc4a780c553#86c22b28cd4a36474d194b44b0bc6fc4a780c553" +source = "git+https://github.com/aroff/cli-framework?rev=388e97b0ecbf244593b033fd5b5bd4ca882bebbb#388e97b0ecbf244593b033fd5b5bd4ca882bebbb" dependencies = [ "aikit-sdk", "ailoop-core", @@ -1913,7 +1913,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2933,7 +2933,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.21", "tokio", "tracing", @@ -2971,7 +2971,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] diff --git a/Cargo.toml b/Cargo.toml index fd8d4dbc..5dd8f8da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,11 +20,11 @@ repository = "https://github.com/gofastskill/fastskill" # and compiles two mutually-incompatible copies into the binary. # # aikit-sdk for agent metadata file resolution -aikit-sdk = { git = "https://github.com/goaikit/aikit.git", rev = "f9c8d2786bc8da2e2d34daf362e6a2a59500502d" } +aikit-sdk = { git = "https://github.com/goaikit/aikit.git", rev = "f91c37f08749b47890bc093e9e0fb8373c8b00ed" } # aikit-evals provides the generic eval runner infrastructure -aikit-evals = { git = "https://github.com/goaikit/aikit.git", rev = "f9c8d2786bc8da2e2d34daf362e6a2a59500502d" } +aikit-evals = { git = "https://github.com/goaikit/aikit.git", rev = "f91c37f08749b47890bc093e9e0fb8373c8b00ed" } # aikit-skillopt provides the text-gradient skill optimization loop -aikit-skillopt = { git = "https://github.com/goaikit/aikit.git", rev = "f9c8d2786bc8da2e2d34daf362e6a2a59500502d" } +aikit-skillopt = { git = "https://github.com/goaikit/aikit.git", rev = "f91c37f08749b47890bc093e9e0fb8373c8b00ed" } # Async runtime tokio = { version = "1.52", features = ["rt-multi-thread", "net", "fs", "io-util", "macros", "process"] } @@ -66,7 +66,7 @@ tower = { version = "0.5", features = ["util", "timeout", "limit"] } tower-http = { version = "0.7", features = ["cors", "trace", "compression-gzip", "fs"] } # CLI framework for versioned API server hosting -cli-framework = { git = "https://github.com/aroff/cli-framework", rev = "86c22b28cd4a36474d194b44b0bc6fc4a780c553", default-features = false, features = ["api-server"] } +cli-framework = { git = "https://github.com/aroff/cli-framework", rev = "388e97b0ecbf244593b033fd5b5bd4ca882bebbb", default-features = false, features = ["api-server"] } # MCP protocol types and transports. Version and feature set MUST track # cli-framework's own `rmcp` dependency: `fastskill mcp serve` wraps diff --git a/crates/fastskill-cli/src/commands/managed.rs b/crates/fastskill-cli/src/commands/managed.rs index c89c2eb0..6ffb2236 100644 --- a/crates/fastskill-cli/src/commands/managed.rs +++ b/crates/fastskill-cli/src/commands/managed.rs @@ -272,7 +272,7 @@ impl Environment { home, project_skills: crate::config::resolve_skills_storage_directory(false).ok(), interactive: std::io::stdout().is_terminal(), - hooks: Some(HookSetup::new(managed::hooks::quote_program(&exe))), + hooks: Some(HookSetup::for_exe(&exe)), timer, exe, background: super::managed_hooks::start_background_apply, diff --git a/crates/fastskill-cli/src/commands/managed_hooks.rs b/crates/fastskill-cli/src/commands/managed_hooks.rs index e3bac94a..34914efe 100644 --- a/crates/fastskill-cli/src/commands/managed_hooks.rs +++ b/crates/fastskill-cli/src/commands/managed_hooks.rs @@ -6,7 +6,7 @@ use super::managed::{Environment, ManagedArgs}; use crate::error::CliResult; -use fastskill_core::core::managed::hooks::{self, agent_of, hook_id, quote_program, HookSetup}; +use fastskill_core::core::managed::hooks::{self, agent_of, hook_id, HookSetup}; use fastskill_core::core::managed::timer::{self, TimerOs}; use fastskill_core::core::managed::{self, ManagedStatus}; use serde_json::{json, Value}; @@ -84,7 +84,7 @@ pub fn run_hooks(env: &Environment, args: &ManagedArgs) -> CliResult { let setup = env .hooks .clone() - .unwrap_or_else(|| HookSetup::new(quote_program(&env.exe))); + .unwrap_or_else(|| HookSetup::for_exe(&env.exe)); let value = if args.system { system_json(&setup, TimerOs::current(), &env.exe) } else { @@ -101,7 +101,7 @@ pub fn run_hooks(env: &Environment, args: &ManagedArgs) -> CliResult { } fn user_json(env: &Environment, setup: &HookSetup) -> Value { - let agents: Vec = aikit_sdk::session_hook_agents() + let agents: Vec = hooks::hooked_agents() .into_iter() .map(|key| { let id = hook_id(key); @@ -160,17 +160,26 @@ fn system_json(setup: &HookSetup, os: Option, exe: &Path) -> Value { fn render_system(value: &Value) -> String { let mut lines = Vec::new(); for hook in value["hooks"].as_array().into_iter().flatten() { - let file = hook["file"].as_str().unwrap_or("(no administrator file)"); - lines.push(format!( - "{}: merge into {file}{}", - hook["agent"].as_str().unwrap_or_default(), - if hook["present"] == true { - " (already present)" - } else { - "" - } - )); - lines.push(serde_json::to_string_pretty(&hook["entry"]).unwrap_or_default()); + let agent = hook["agent"].as_str().unwrap_or_default(); + let (Some(file), Some(text)) = (hook["file"].as_str(), hook["text"].as_str()) else { + lines.push(format!( + "{agent}: no administrator-level location; the machine-wide timer covers it" + )); + lines.push(String::new()); + continue; + }; + let verb = if hook["format"] == "toml" { + "add to" + } else { + "merge into" + }; + let present = if hook["present"] == true { + " (already present)" + } else { + "" + }; + lines.push(format!("{agent}: {verb} {file}{present}")); + lines.push(text.trim_end().to_string()); lines.push(String::new()); } let plan = &value["timer"]; diff --git a/crates/fastskill-cli/src/commands/managed_hooks_tests.rs b/crates/fastskill-cli/src/commands/managed_hooks_tests.rs index 23f85f1b..66c54010 100644 --- a/crates/fastskill-cli/src/commands/managed_hooks_tests.rs +++ b/crates/fastskill-cli/src/commands/managed_hooks_tests.rs @@ -104,6 +104,25 @@ fn a_hook_starts_an_apply_and_prints_what_the_user_must_act_on() { .contains("no managed skills have been applied")); } +#[test] +fn each_agent_gets_the_notice_in_its_own_form() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, true, false); + enroll_record(&env); + let notice = + notice(&managed::status(&env.layout, &env.settings, chrono::Utc::now()).unwrap()).unwrap(); + let printed = |agent: &str| run_hook(&env, &hook_id(agent)).unwrap(); + let field = |agent: &str, key: &str| { + let value: Value = serde_json::from_str(&printed(agent)).unwrap(); + value[key].as_str().unwrap().to_string() + }; + assert_eq!(field("claude", "systemMessage"), notice); + assert_eq!(field("codex", "systemMessage"), notice); + // Cursor passes the text to the model; Pi shows plain text in its interface. + assert_eq!(field("cursor", "additional_context"), notice); + assert_eq!(printed("pi"), notice); +} + #[test] fn a_hook_does_nothing_when_there_is_nothing_to_do() { let dir = TempDir::new().unwrap(); @@ -165,14 +184,20 @@ fn hooks_show_each_agent_and_the_timer() { let dir = TempDir::new().unwrap(); let mut env = environment(&dir, true, false); let setup = hook_setup(no_admin); - aikit_sdk::register_session_hook(&env.home, "claude", &setup.hook("claude")).unwrap(); - std::fs::create_dir_all(env.home.join(".gemini")).unwrap(); - std::fs::write(env.home.join(".gemini/settings.json"), "not json").unwrap(); + for agent in ["claude", "pi"] { + aikit_sdk::register_session_hook(&env.home, agent, &setup.hook(agent)).unwrap(); + } + std::fs::create_dir_all(env.home.join(".cursor")).unwrap(); + std::fs::write(env.home.join(".cursor/hooks.json"), "not json").unwrap(); env.hooks = Some(setup); let text = run_hooks(&env, &args(false, false)).unwrap(); assert!(text.contains("claude user-level"), "{text}"); - assert!(text.contains("gemini unknown: "), "{text}"); + assert!(text.contains("codex none"), "{text}"); + assert!(text.contains("cursor unknown: "), "{text}"); + assert!(text.contains("pi user-level"), "{text}"); + // Gemini CLI is reached by the timer alone. + assert!(!text.contains("gemini"), "{text}"); assert!(text.contains("Timer: none on this platform"), "{text}"); let mut timer = TimerSetup { @@ -192,7 +217,15 @@ fn hooks_show_each_agent_and_the_timer() { timer::install(&timer).unwrap(); let value: Value = serde_json::from_str(&run_hooks(&env, &args(true, false)).unwrap()).unwrap(); + let agents: Vec<&str> = value["agents"] + .as_array() + .unwrap() + .iter() + .map(|agent| agent["agent"].as_str().unwrap()) + .collect(); + assert_eq!(agents, vec!["claude", "codex", "cursor", "pi"]); assert_eq!(value["agents"][0]["user"], true); + assert!(value["agents"][2]["problem"].is_string()); assert_eq!(value["timer"]["installed"], true); let text = run_hooks(&env, &args(false, false)).unwrap(); assert!( @@ -222,18 +255,39 @@ fn system_hooks_print_what_an_administrator_installs() { let dir = TempDir::new().unwrap(); let env = environment(&dir, true, false); let value: Value = serde_json::from_str(&run_hooks(&env, &args(true, true)).unwrap()).unwrap(); - assert_eq!(value["hooks"][0]["agent"], "claude"); + let hooks = value["hooks"].as_array().unwrap(); + let agents: Vec<&str> = hooks + .iter() + .map(|hook| hook["agent"].as_str().unwrap()) + .collect(); + assert_eq!(agents, vec!["claude", "codex", "cursor", "pi"]); assert_eq!(value["timer"].is_null(), TimerOs::current().is_none()); // Without a setup, the hooks run this executable. - assert!(value["hooks"][0]["entry"] - .to_string() + assert_eq!(hooks[0]["format"], "json"); + assert!(hooks[0]["text"] + .as_str() + .unwrap() .contains("managed apply --hook fastskill-managed-claude")); + assert_eq!(hooks[1]["format"], "toml"); + assert!(hooks[1]["text"] + .as_str() + .unwrap() + .contains("[[hooks.SessionStart]]")); + assert!(hooks[3]["file"].is_null() && hooks[3]["text"].is_null()); let setup = hook_setup(claude_admin); for os in [TimerOs::Linux, TimerOs::MacOs, TimerOs::Windows] { let text = render_system(&system_json(&setup, Some(os), &env.exe)); assert!(text.contains("claude: merge into "), "{text}"); assert!(text.contains("(already present)"), "{text}"); + assert!(text.contains("codex: add to "), "{text}"); + assert!(text.contains("[[hooks.SessionStart]]"), "{text}"); + assert!(text.contains("cursor: merge into "), "{text}"); + assert!( + text.contains("pi: no administrator-level location; the machine-wide timer covers it"), + "{text}" + ); + assert!(!text.contains("gemini"), "{text}"); assert!(text.contains("Machine-wide timer:\n"), "{text}"); // A machine-wide launch agent loads at each user's next login. assert_eq!(text.contains("then run: "), os != TimerOs::MacOs, "{text}"); diff --git a/crates/fastskill-core/src/core/managed/hooks.rs b/crates/fastskill-core/src/core/managed/hooks.rs index f4eef3b5..20df7915 100644 --- a/crates/fastskill-core/src/core/managed/hooks.rs +++ b/crates/fastskill-core/src/core/managed/hooks.rs @@ -1,21 +1,25 @@ //! Session-start hooks for the covered agents (ADR-0016 decisions 16 and 17). //! -//! Each covered agent that can run a command when a session starts gets a user-level hook -//! running `managed apply --hook `, where `` is [`hook_id`] for that agent. Every apply -//! registers hooks for newly covered agents and removes those of agents no longer covered. An -//! agent whose administrator-level settings already hold FastSkill's hook gets no user-level -//! one. `unenroll` removes them all. +//! FastSkill hooks only the agents in [`HOOKED_AGENTS`]: Claude Code, Codex, Cursor and Pi. Each +//! covered one gets a user-level hook running `managed apply --hook `, where `` is +//! [`hook_id`] for that agent. Every apply registers hooks for newly covered agents and removes +//! FastSkill's hook from every other agent `aikit-sdk` supports, Gemini CLI included, which the +//! timer alone reaches. An agent whose administrator-level settings already hold FastSkill's +//! hook gets no user-level one. `unenroll` removes them all. -use aikit_sdk::{HookChange, SessionHook}; -use std::path::Path; +use aikit_sdk::{AdminHookFormat, HookChange, SessionHook}; +use std::path::{Path, PathBuf}; /// The prefix of every hook id; the agent key follows it. pub const HOOK_ID_PREFIX: &str = "fastskill-managed-"; +/// The `aikit-sdk` keys of the agents FastSkill hooks at session start (third amendment). +pub const HOOKED_AGENTS: &[&str] = &["claude", "codex", "cursor", "pi"]; + /// How to register hooks for one apply. #[derive(Debug, Clone)] pub struct HookSetup { - /// The program part of the hook command, already quoted for the agent's shell. + /// The absolute path of the FastSkill program the hooks run. pub program: String, /// Whether the administrator-level settings of agent `key` hold the hook with id `id`. pub admin_present: fn(&str, &str) -> bool, @@ -30,6 +34,11 @@ impl HookSetup { } } + /// Hooks that run the executable at `path`. + pub fn for_exe(path: &Path) -> Self { + Self::new(path.to_string_lossy()) + } + /// The hook for agent `key`. pub fn hook(&self, key: &str) -> SessionHook { hook_for(&self.program, key) @@ -47,18 +56,21 @@ pub fn agent_of(id: &str) -> Option<&str> { .filter(|key| !key.is_empty()) } -/// The hook running `program` for agent `key`. +/// The hook running `program managed apply --hook ` for agent `key`. `aikit-sdk` derives the +/// shell command agents store from these arguments. pub fn hook_for(program: &str, key: &str) -> SessionHook { let id = hook_id(key); - SessionHook { - command: format!("{program} managed apply --hook {id}"), - id, - } + SessionHook::from_argv(id.clone(), program, ["managed", "apply", "--hook", &id]) } -/// Quote `path` as the program of a hook command. -pub fn quote_program(path: &Path) -> String { - format!("\"{}\"", path.display().to_string().replace('"', "\\\"")) +/// The hooked agents `aikit-sdk` supports, in [`HOOKED_AGENTS`] order. +pub fn hooked_agents() -> Vec<&'static str> { + let supported = aikit_sdk::session_hook_agents(); + HOOKED_AGENTS + .iter() + .copied() + .filter(|key| supported.contains(key)) + .collect() } /// What registering hooks did. @@ -66,24 +78,26 @@ pub fn quote_program(path: &Path) -> String { pub struct HookSync { /// Covered agents with a hook, user- or administrator-level. pub hooked: Vec, - /// Notes for hooks just added to an agent that needs the user to approve them. + /// Notes for hooks just added or changed in an agent that needs the user to approve them. pub approvals: Vec, /// Hooks that couldn't be registered or removed, with why. pub warnings: Vec, } -/// Register the hook for every covered agent that supports one, and remove FastSkill's -/// user-level hook from every other supported agent. +/// Register the hook for every covered agent FastSkill hooks, and remove FastSkill's user-level +/// hook from every other agent `aikit-sdk` supports. pub fn sync(home: &Path, setup: &HookSetup, covered: &[&str]) -> HookSync { let mut sync = HookSync::default(); for key in aikit_sdk::session_hook_agents() { let id = hook_id(key); - let admin = covered.contains(&key) && (setup.admin_present)(key, &id); - if covered.contains(&key) && !admin { + let wanted = HOOKED_AGENTS.contains(&key) && covered.contains(&key); + let admin = wanted && (setup.admin_present)(key, &id); + if wanted && !admin { match aikit_sdk::register_session_hook(home, key, &setup.hook(key)) { Ok(change) => { sync.hooked.push(key.to_string()); - if change == HookChange::Added { + // An agent that approves hooks asks again when one changes. + if matches!(change, HookChange::Added | HookChange::Updated) { if let Some(note) = aikit_sdk::session_hook_support(key) .and_then(|support| support.approval_note) { @@ -123,28 +137,38 @@ pub fn remove_all(home: &Path) -> (Vec, Vec) { (removed, warnings) } -/// One agent's administrator-level hook: where it goes and what to merge there. +/// One hooked agent's administrator-level hook: where it goes and what to add there. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct AdminHook { pub agent: String, - pub file: Option, - pub entry: serde_json::Value, + /// The administrator file, or `None` when the agent has no administrator-level location. + pub file: Option, + /// `json` or `toml`, the administrator file's format; `None` without a location. + pub format: Option<&'static str>, + /// The fragment to merge into `file`; `None` without a location. + pub text: Option, pub present: bool, } -/// The administrator-level hook entry for every supported agent, writing nothing. +/// The administrator-level hook entry for every hooked agent, writing nothing. An agent without +/// an administrator-level location (Pi) has no file, format or text; the machine-wide timer +/// covers it. pub fn admin_hooks(setup: &HookSetup) -> Vec { - aikit_sdk::session_hook_agents() + hooked_agents() .into_iter() - .filter_map(|key| { + .map(|key| { let hook = setup.hook(key); - let entry = aikit_sdk::admin_hook_entry(key, &hook).ok()?; - Some(AdminHook { + let entry = aikit_sdk::admin_hook_entry(key, &hook).ok(); + AdminHook { agent: key.to_string(), - file: aikit_sdk::admin_hook_file(key), + file: entry.as_ref().and_then(|_| aikit_sdk::admin_hook_file(key)), + format: entry.as_ref().map(|entry| match entry.format { + AdminHookFormat::Json => "json", + AdminHookFormat::Toml => "toml", + }), present: (setup.admin_present)(key, &hook.id), - entry, - }) + text: entry.map(|entry| entry.text), + } }) .collect() } diff --git a/crates/fastskill-core/src/core/managed/hooks_tests.rs b/crates/fastskill-core/src/core/managed/hooks_tests.rs index b9a38780..d56e2296 100644 --- a/crates/fastskill-core/src/core/managed/hooks_tests.rs +++ b/crates/fastskill-core/src/core/managed/hooks_tests.rs @@ -4,7 +4,7 @@ use super::*; fn setup(admin_present: fn(&str, &str) -> bool) -> HookSetup { HookSetup { - program: quote_program(Path::new("/opt/fast skill/fastskill")), + program: "/opt/fast skill/fastskill".to_string(), admin_present, } } @@ -21,43 +21,95 @@ fn has(home: &Path, key: &str) -> bool { aikit_sdk::has_session_hook(home, key, &hook_id(key)).unwrap() } +const ALL: &[&str] = &["claude", "codex", "cursor", "pi", "gemini", "copilot"]; + #[test] fn a_hook_names_its_agent_and_runs_apply_in_hook_mode() { let hook = setup(no_admin).hook("claude"); assert_eq!(hook.id, "fastskill-managed-claude"); assert_eq!( - hook.command, - "\"/opt/fast skill/fastskill\" managed apply --hook fastskill-managed-claude" + hook.argv.as_deref(), + Some( + &[ + "/opt/fast skill/fastskill", + "managed", + "apply", + "--hook", + "fastskill-managed-claude" + ] + .map(String::from)[..] + ) ); + if cfg!(unix) { + assert_eq!( + hook.command, + "'/opt/fast skill/fastskill' managed apply --hook fastskill-managed-claude" + ); + } assert_eq!(agent_of("fastskill-managed-gemini"), Some("gemini")); assert_eq!(agent_of("fastskill-managed-"), None); assert_eq!(agent_of("other"), None); - assert_eq!(quote_program(Path::new("a\"b")), "\"a\\\"b\""); assert_eq!(HookSetup::new("x").program, "x"); + assert_eq!(HookSetup::for_exe(Path::new("/bin/fs")).program, "/bin/fs"); + assert_eq!(hooked_agents(), HOOKED_AGENTS); } #[test] -fn sync_registers_covered_agents_and_removes_the_others() { +fn sync_hooks_the_four_agents_and_no_other() { let home = tempfile::tempdir().unwrap(); let setup = setup(no_admin); - let first = sync(home.path(), &setup, &["claude", "gemini", "codex"]); - assert_eq!(first.hooked, vec!["claude", "gemini"]); + let first = sync(home.path(), &setup, ALL); + assert_eq!(first.hooked, vec!["claude", "codex", "cursor", "pi"]); assert!(first.warnings.is_empty(), "{:?}", first.warnings); - assert_eq!(first.approvals.len(), 1); - assert!(first.approvals[0].starts_with("claude: ")); - assert!(has(home.path(), "claude") && has(home.path(), "gemini")); + let noted: Vec<&str> = first + .approvals + .iter() + .map(|note| note.split(':').next().unwrap()) + .collect(); + assert_eq!(noted, vec!["claude", "codex"]); + for key in HOOKED_AGENTS { + assert!(has(home.path(), key), "{key}"); + } + assert!(!has(home.path(), "gemini")); - let again = sync(home.path(), &setup, &["claude", "gemini"]); - assert_eq!(again.hooked, vec!["claude", "gemini"]); + let again = sync(home.path(), &setup, ALL); + assert_eq!(again.hooked, first.hooked); assert!(again.approvals.is_empty()); - let fewer = sync(home.path(), &setup, &["claude"]); - assert_eq!(fewer.hooked, vec!["claude"]); - assert!(has(home.path(), "claude")); + let fewer = sync(home.path(), &setup, &["claude", "pi"]); + assert_eq!(fewer.hooked, vec!["claude", "pi"]); + assert!(has(home.path(), "claude") && has(home.path(), "pi")); + assert!(!has(home.path(), "codex") && !has(home.path(), "cursor")); +} + +#[test] +fn sync_removes_a_gemini_hook_left_from_before() { + let home = tempfile::tempdir().unwrap(); + let setup = setup(no_admin); + aikit_sdk::register_session_hook(home.path(), "gemini", &setup.hook("gemini")).unwrap(); + assert!(has(home.path(), "gemini")); + + let synced = sync(home.path(), &setup, &["claude", "gemini"]); + assert_eq!(synced.hooked, vec!["claude"]); + assert!(synced.warnings.is_empty(), "{:?}", synced.warnings); assert!(!has(home.path(), "gemini")); } +#[test] +fn a_changed_codex_hook_asks_for_approval_again() { + let home = tempfile::tempdir().unwrap(); + sync(home.path(), &setup(no_admin), &["codex"]); + let moved = HookSetup { + program: "/usr/local/bin/fastskill".to_string(), + admin_present: no_admin, + }; + let synced = sync(home.path(), &moved, &["codex"]); + assert_eq!(synced.approvals.len(), 1, "{:?}", synced.approvals); + assert!(synced.approvals[0].starts_with("codex: ")); + assert!(sync(home.path(), &moved, &["codex"]).approvals.is_empty()); +} + #[test] fn an_administrator_hook_replaces_the_user_one() { let home = tempfile::tempdir().unwrap(); @@ -66,11 +118,12 @@ fn an_administrator_hook_replaces_the_user_one() { let synced = sync(home.path(), &setup(claude_admin), &["claude"]); assert_eq!(synced.hooked, vec!["claude"]); + assert!(synced.approvals.is_empty()); assert!(!has(home.path(), "claude")); } #[test] -fn a_settings_file_that_cannot_be_changed_is_a_warning() { +fn a_hook_file_that_cannot_be_changed_is_a_warning() { let home = tempfile::tempdir().unwrap(); for folder in [".claude", ".gemini"] { std::fs::create_dir_all(home.path().join(folder)).unwrap(); @@ -87,26 +140,70 @@ fn a_settings_file_that_cannot_be_changed_is_a_warning() { assert_eq!(warnings.len(), 2); } +#[test] +fn a_codex_config_with_hooks_and_a_foreign_pi_file_are_warnings() { + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join(".codex")).unwrap(); + let config = "[[hooks.SessionStart]]\nhooks = []\n"; + std::fs::write(home.path().join(".codex/config.toml"), config).unwrap(); + let extensions = home.path().join(".pi/agent/extensions"); + std::fs::create_dir_all(&extensions).unwrap(); + let mine = extensions.join("fastskill-managed-pi.ts"); + std::fs::write(&mine, "export default function () {}\n").unwrap(); + + let synced = sync(home.path(), &setup(no_admin), &["codex", "pi"]); + assert!(synced.hooked.is_empty(), "{:?}", synced.hooked); + assert!(synced.approvals.is_empty()); + assert_eq!(synced.warnings.len(), 2, "{:?}", synced.warnings); + assert!(synced.warnings[0].contains("can't register the codex")); + assert!(synced.warnings[1].contains("can't register the pi")); + assert_eq!( + std::fs::read_to_string(home.path().join(".codex/config.toml")).unwrap(), + config + ); + assert!(!home.path().join(".codex/hooks.json").exists()); + assert_eq!( + std::fs::read_to_string(&mine).unwrap(), + "export default function () {}\n" + ); + // Unenrolling leaves the file FastSkill didn't write in place too. + remove_all(home.path()); + assert!(mine.exists()); +} + #[test] fn remove_all_removes_every_user_hook() { let home = tempfile::tempdir().unwrap(); - sync(home.path(), &setup(no_admin), &["claude", "gemini"]); + let setup = setup(no_admin); + sync(home.path(), &setup, ALL); + aikit_sdk::register_session_hook(home.path(), "gemini", &setup.hook("gemini")).unwrap(); let (removed, warnings) = remove_all(home.path()); - assert_eq!(removed, vec!["claude", "gemini"]); - assert!(warnings.is_empty()); - assert!(!has(home.path(), "claude") && !has(home.path(), "gemini")); + assert_eq!(removed, vec!["claude", "gemini", "codex", "cursor", "pi"]); + assert!(warnings.is_empty(), "{warnings:?}"); + for key in ["claude", "codex", "cursor", "gemini", "pi"] { + assert!(!has(home.path(), key), "{key}"); + } assert_eq!(remove_all(home.path()).0, Vec::::new()); } #[test] -fn admin_hooks_give_each_agent_its_entry_without_writing() { +fn admin_hooks_give_each_hooked_agent_its_entry_without_writing() { let hooks = admin_hooks(&setup(claude_admin)); let agents: Vec<&str> = hooks.iter().map(|hook| hook.agent.as_str()).collect(); - assert_eq!(agents, vec!["claude", "gemini"]); + assert_eq!(agents, HOOKED_AGENTS); assert!(hooks[0].present && !hooks[1].present); - let text = hooks[1].entry.to_string(); - assert!(text.contains("SessionStart"), "{text}"); - assert!(text.contains("--hook fastskill-managed-gemini"), "{text}"); + let formats: Vec> = hooks.iter().map(|hook| hook.format).collect(); + assert_eq!( + formats, + vec![Some("json"), Some("toml"), Some("json"), None] + ); + let codex = hooks[1].text.as_deref().unwrap(); + assert!(codex.contains("[[hooks.SessionStart]]"), "{codex}"); + assert!(codex.contains("fastskill-managed-codex"), "{codex}"); + let cursor = hooks[2].text.as_deref().unwrap(); + assert!(cursor.contains("sessionStart"), "{cursor}"); + let pi = &hooks[3]; + assert!(pi.file.is_none() && pi.text.is_none() && !pi.present); } mod with_apply { diff --git a/docs/adr/0016-machines-follow-a-signed-managed-state.md b/docs/adr/0016-machines-follow-a-signed-managed-state.md index 59fdf8da..009c0182 100644 --- a/docs/adr/0016-machines-follow-a-signed-managed-state.md +++ b/docs/adr/0016-machines-follow-a-signed-managed-state.md @@ -1,7 +1,7 @@ # Machines can follow a signed managed state; FastSkill applies it and never decides it Status: accepted. Date: 2026-09-24. Revised: 2026-09-24. Amended: 2026-09-24. Accepted: -2026-10-09. Implemented: 2026-10-10. Third amendment proposed: 2026-10-17. +2026-10-09. Implemented: 2026-10-10. Third amendment accepted and implemented: 2026-10-17. Related: [ADR-0001](0001-remove-sync-command.md), [ADR-0003](0003-serve-trust-boundary-and-edge-auth.md), @@ -50,7 +50,7 @@ skills and run hooks (see Agent facts, at the end): - The credential command is told when there is no terminal, and a sign-in that lapsed is shown instead of failing silently (decisions 6 and 16). -**Third amendment** (proposed 2026-10-17). Session-start hooks are narrowed to four agents and +**Third amendment** (accepted and implemented 2026-10-17). Session-start hooks are narrowed to four agents and widened to one that needs a plugin. The plan is [managed session hooks](../../specs/managed-session-hooks-prd.md): @@ -612,6 +612,11 @@ Rechecked for the four hooked agents on 2026-10-17 (third amendment): | Agent | User hook file | Hook event and output | Approval | Administrator-level hooks | |---|---|---|---|---| | Claude Code | `~/.claude/settings.json` | `SessionStart`; `systemMessage` is shown | review in `/hooks` | managed settings | -| Codex CLI | `~/.codex/hooks.json` | `SessionStart`, matcher `startup\|resume`; `systemMessage` is shown as a warning, plain output reaches the model | trust in `/hooks`, again whenever the hook changes | `[hooks]` in `requirements.toml`, trusted by policy | +| Codex CLI | `~/.codex/hooks.json` | `SessionStart`, matcher `startup\|resume`; `systemMessage` is shown as a `Hook` line in the transcript, plain output reaches the model | trust in `/hooks`, again whenever the hook changes | `[hooks]` in `requirements.toml`, trusted by policy | | Cursor | `~/.cursor/hooks.json` | `sessionStart`, not awaited; only `additional_context` reaches the model, nothing is shown | none | `/etc/cursor/hooks.json` and the team dashboard | | Pi | an extension in `~/.pi/agent/extensions` | `session_start` extension event, awaited; the extension shows a notice with `ctx.ui.notify` | none | none | + +Checked on real installs on 2026-10-17: Claude Code 2.1.302, Codex CLI 0.163.0, Cursor CLI +2026.10.16-e3cb207 and Pi 1.3.0 each ran the hook and showed the notice as above. The Cursor CLI +runs `sessionStart` only when a process starts a new chat; a chat started with `/clear`, +`--resume` or `--continue` runs none, and the timer covers it. Cursor's IDE wasn't checked. diff --git a/specs/managed-session-hooks-prd.md b/specs/managed-session-hooks-prd.md index 32f9970e..1ee7369f 100644 --- a/specs/managed-session-hooks-prd.md +++ b/specs/managed-session-hooks-prd.md @@ -1,6 +1,6 @@ # PRD: Managed session hooks for Claude Code, Codex, Cursor and Pi -Status: proposed. +Status: implemented (2026-10-17). Date: 2026-10-17. This PRD plans the third amendment of @@ -134,6 +134,9 @@ entry for each of the four agents that has one from `managed hooks --system`. reaches them. - Whether Codex's IDE extension and app run hooks is not documented. Only Codex CLI is claimed. - Cursor shows the notice to the model, not the user. +- The Cursor CLI (checked on 2026.10.16-e3cb207) runs the hook only when a process starts a new + chat; a chat started with `/clear`, `--resume` or `--continue` runs none, and the timer still + reaches it. Cursor's IDE wasn't checked. ## Testing decisions diff --git a/webdocs/cli-reference/managed-command.mdx b/webdocs/cli-reference/managed-command.mdx index 876f4717..d85f1519 100644 --- a/webdocs/cli-reference/managed-command.mdx +++ b/webdocs/cli-reference/managed-command.mdx @@ -168,10 +168,12 @@ fastskill managed unenroll ### hooks -Show this user's session-start hook for each agent that supports one, and the timer. With -`--system`, print instead what an administrator installs for every user: the entry to merge -into each agent's administrator-level settings, and the machine-wide timer files and commands. -Nothing is written. +Show this user's session-start hook for each of the four agents FastSkill hooks (Claude Code, +Codex CLI, Cursor and Pi), and the timer. With `--system`, print instead what an administrator +installs for every user: each agent's administrator-level entry in that file's format (JSON for +Claude Code and Cursor, TOML for Codex), the file it goes in, and the machine-wide timer files and +commands. Pi has no administrator-level location; the machine-wide timer covers it. With +`--json`, each entry carries `agent`, `file`, `format`, `text` and `present`. Nothing is written. ```bash fastskill managed hooks @@ -186,12 +188,25 @@ fastskill managed hooks --system --json Two things run `managed apply` without anyone typing it: -- **Session-start hooks.** Every apply registers a hook in the user settings of each covered - agent that can run a command when a session starts (Claude Code and Gemini CLI), and removes - it from agents no longer covered. The hook starts an apply in the background and returns at - once. It prints nothing unless the user must act, such as to sign in, and then one notice. - Claude Code asks the user to approve a new hook once; the apply that adds it says so. An - agent whose administrator-level settings already hold the hook gets no user-level one. +- **Session-start hooks.** Every apply registers a user-level hook in each covered agent of + the four FastSkill hooks, and removes FastSkill's hook from every other agent, Gemini CLI + included, which the timer alone keeps up to date. The hook starts an apply in the background + and returns at once. It prints nothing unless the user must act, such as to sign in, and then + one notice. An agent whose administrator-level settings already hold the hook gets no + user-level one. A hook file FastSkill can't change is a warning in the apply's report. + + | Agent | User-level hook | Approval | Where the notice shows | + | --- | --- | --- | --- | + | Claude Code | `~/.claude/settings.json` | it may ask you to review the hook in `/hooks` | in the session | + | Codex CLI | `~/.codex/hooks.json` | trust it in `/hooks`; again after it changes | a `Hook` line in the transcript | + | Cursor (IDE and CLI) | `~/.cursor/hooks.json` | none | not shown; it reaches the model | + | Pi | `~/.pi/agent/extensions/fastskill-managed-pi.ts` | none | a warning in Pi's interface; nothing without one | + + The apply that adds a hook needing approval, or changes one, says so. Codex runs no hook from + `~/.codex/hooks.json` while `~/.codex/config.toml` defines hooks of its own, so FastSkill warns + and writes nothing there. FastSkill never overwrites or removes a Pi extension it didn't write. + Cursor's CLI runs the hook only when it starts a new chat, not for `/clear`, `--resume` or + `--continue`; the timer covers those. - **A timer.** Enrolling, including the first apply that enrolls, installs a timer that applies shortly after login and then every hour, each time after a random wait of up to 10 minutes: a systemd user timer on Linux, a launchd agent on macOS, and a scheduled task on Windows. No diff --git a/webdocs/security/managed-machines.mdx b/webdocs/security/managed-machines.mdx index 852c8811..56f70485 100644 --- a/webdocs/security/managed-machines.mdx +++ b/webdocs/security/managed-machines.mdx @@ -105,15 +105,21 @@ user's next `fastskill managed apply` from a terminal signs in again. ## Administrator-level hooks -`fastskill managed hooks --system` prints, for each agent that runs a command at session start, -the entry to merge into the agent's administrator-level settings. Device management places those +`fastskill managed hooks --system` prints, for each agent FastSkill hooks, the entry to add to +the agent's administrator-level file, in that file's format. Device management places those files; FastSkill never writes them. When an administrator-level FastSkill hook is present for an agent, apply registers no user-level one, so there is no hook for the user to approve. -| Agent | Linux | macOS | Windows | -| --- | --- | --- | --- | -| Claude Code | `/etc/claude-code/managed-settings.json` | `/Library/Application Support/ClaudeCode/managed-settings.json` | `C:\Program Files\ClaudeCode\managed-settings.json` | -| Gemini CLI | `/etc/gemini-cli/settings.json` | `/Library/Application Support/GeminiCli/settings.json` | `C:\ProgramData\gemini-cli\settings.json` | +| Agent | Format | Linux | macOS | Windows | +| --- | --- | --- | --- | --- | +| Claude Code | JSON | `/etc/claude-code/managed-settings.json` | `/Library/Application Support/ClaudeCode/managed-settings.json` | `C:\Program Files\ClaudeCode\managed-settings.json` | +| Codex CLI | TOML | `/etc/codex/requirements.toml` | `/etc/codex/requirements.toml` | `C:\ProgramData\OpenAI\Codex\requirements.toml` | +| Cursor | JSON | `/etc/cursor/hooks.json` | `/Library/Application Support/Cursor/hooks.json` | `C:\ProgramData\Cursor\hooks.json` | +| Pi | none | none | none | none | + +Codex trusts hooks in `requirements.toml` by policy, so users approve nothing. Cursor's hooks can +also be set from the team dashboard. Pi has no administrator-level location; the machine-wide +timer covers it. ```bash fastskill managed hooks --system @@ -145,7 +151,7 @@ documentation on 2026-09-24. "Shared folder" says whether the agent reads `~/.ag | --- | --- | --- | --- | --- | | Claude Code | no | yes; output reaches the model | managed settings | no | | Cursor (IDE and CLI) | yes | yes, not awaited; output not shown | `/etc/cursor/hooks.json` | no | -| Codex CLI | yes | yes; user hooks need approval | managed hooks, no approval | later | +| Codex CLI | yes | yes; user hooks need trust in `/hooks` | `requirements.toml`, no approval | later | | Gemini CLI | yes | yes; output must be JSON | `/etc/gemini-cli/settings.json` | no | | Qwen Code | yes | yes | `/etc/qwen-code` | later | | GitHub Copilot (VS Code) | yes | yes; only JSON context reaches the model | GitHub policy | no | @@ -154,13 +160,22 @@ documentation on 2026-09-24. "Shared folder" says whether the agent reads `~/.ag | Auggie | yes | yes | `/etc/augment/settings.json` | no | | Kiro, CodeBuddy, Qoder | no | yes | not checked | no | | IBM Bob | no | yes; 10 s timeout | enforced-hooks policy | no | -| OpenCode, Amp, Pi, Kilo | yes | only through a plugin | not checked | no | +| Pi | yes | through an extension; FastSkill writes its own | none | no | +| OpenCode, Amp, Kilo | yes | only through a plugin | not checked | no | | Roo Code (archived) | yes | none | none | no | "Later" means the agent watches its skills folders and picks the skill up during the session. -FastSkill registers session-start hooks where it has verified support (today Claude Code and -Gemini CLI); every other agent is kept up to date by the timer. Agents add hooks and folders -often, so check the agent's current documentation before relying on a row. +FastSkill registers session-start hooks for four agents: Claude Code, Codex CLI, Cursor (IDE and +CLI) and Pi, whose extension FastSkill writes and owns whole. Every other agent, Gemini CLI +included, is kept up to date by the timer, and an apply removes a FastSkill hook left in any of +them. Agents add hooks and folders often, so check the agent's current documentation before +relying on a row. + +Known gaps: Pi installs that move the agent folder with `PI_CODING_AGENT_DIR` get no hook, and +the timer still reaches them. Only Codex CLI is claimed, not Codex's IDE extension or app. +Cursor passes the notice to the model, not the user, so check `fastskill managed status` when +skills seem stale. The Cursor CLI runs the hook only when it starts a new chat; a chat started +with `/clear`, `--resume` or `--continue` waits for the timer. ## Several users on one machine