Synced from monorepo
Synced from monorepo Changes: - Report invalid MCP server config instead of failing startup - Keep completed terminal output when the gateway connection is lost - Show a duration-only detail view for single-task task output - Don't let a stale registry turn counter hide local sessions - Raise the file-descriptor soft limit on Linux and log effective limits at startup - Stop aborting when HTTP client construction fails - Make session thread and runtime spawn failures recoverable - Fix main-prompt paste parity in the question freeform input - Fire SessionEnd hooks on /exit and headless quit - Embed the deployment-config signing public key - Repaint paste-chip background on inline panel inputs - Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root - Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker - Parse hooks from config files - Add a remote kill-switch for managed-config signature verification - Security: fix workspace file-reference resolution bypassing workspace filesystem confinement Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -21,6 +21,11 @@ pub enum AgentBuildError {
|
|||
#[error("IO error during agent construction: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
/// Failed to build the session's tokio runtime (fd exhaustion: the
|
||||
/// runtime needs epoll/kqueue + waker fds).
|
||||
#[error("failed to build session runtime: {0}")]
|
||||
RuntimeBuild(std::io::Error),
|
||||
|
||||
/// MiniJinja template rendering failed (extend or full mode).
|
||||
/// Includes line numbers and context from the template.
|
||||
#[error("template rendering error: {0}")]
|
||||
|
|
|
|||
|
|
@ -1,68 +1,15 @@
|
|||
//! Plugin hooks adapter — pre-filter and source-entry builder.
|
||||
//!
|
||||
//! This module is a bridge between plugin hook JSON files and the shared
|
||||
//! `xai-grok-hooks` runtime. It pre-filters unsupported events from plugin
|
||||
//! hook files before passing them to `parse_hook_file()`, and injects
|
||||
//! plugin-specific environment variables into the resulting `HookSpec` entries.
|
||||
//!
|
||||
//! This is NOT a second hooks engine — it feeds into the existing
|
||||
//! `xai-grok-hooks` crate's parser and runtime.
|
||||
//! Plugin hooks adapter: pre-filter plugin hook JSON, then feed it to
|
||||
//! `xai-grok-hooks`' parser and inject plugin env vars. Not a second engine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use xai_grok_hooks::config::{HookSpec, parse_hook_file};
|
||||
use xai_grok_hooks::event::HookEventName;
|
||||
|
||||
use super::manifest::substitute_env_vars;
|
||||
|
||||
/// Supported hook event names.
|
||||
/// Both PascalCase and snake_case forms are accepted.
|
||||
const SUPPORTED_EVENTS: &[&str] = &[
|
||||
// v0 events — PascalCase and snake_case
|
||||
"SessionStart",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"SessionEnd",
|
||||
"session_start",
|
||||
"pre_tool_use",
|
||||
"post_tool_use",
|
||||
"session_end",
|
||||
// v2 events — PascalCase and snake_case
|
||||
"Notification",
|
||||
"Stop",
|
||||
"StopFailure",
|
||||
"UserPromptSubmit",
|
||||
"PostToolUseFailure",
|
||||
"PermissionDenied",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
// `SubagentEnd` is the legacy alias for `SubagentStop`.
|
||||
"SubagentEnd",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"notification",
|
||||
"stop",
|
||||
"stop_failure",
|
||||
"user_prompt_submit",
|
||||
"post_tool_use_failure",
|
||||
"permission_denied",
|
||||
"subagent_start",
|
||||
"subagent_stop",
|
||||
"subagent_end",
|
||||
"pre_compact",
|
||||
"post_compact",
|
||||
];
|
||||
|
||||
/// Parse plugin hook files with pre-filtering and env injection.
|
||||
///
|
||||
/// For each trusted plugin with hooks, this function:
|
||||
/// 1. Reads the hooks JSON file
|
||||
/// 2. Pre-filters unsupported event names (avoiding parse failures)
|
||||
/// 3. Parses via `parse_hook_file()`
|
||||
/// 4. Injects plugin-specific env vars into each resulting `HookSpec`
|
||||
///
|
||||
/// Returns `(specs, warnings)` — specs are ready to merge into the
|
||||
/// `HookRegistry`, warnings are unsupported-handler or parse errors.
|
||||
/// Read, pre-filter, parse, and env-inject a plugin's hooks file.
|
||||
pub fn parse_plugin_hooks(
|
||||
hooks_path: &Path,
|
||||
plugin_name: &str,
|
||||
|
|
@ -93,11 +40,7 @@ pub fn parse_plugin_hooks(
|
|||
(specs, warnings)
|
||||
}
|
||||
|
||||
/// Parse inline hooks from a manifest JSON value.
|
||||
///
|
||||
/// Same pipeline as [`parse_plugin_hooks()`] but skips the file I/O step.
|
||||
/// The `value` is expected to be the manifest's inline hooks object,
|
||||
/// structured as `{ "hooks": { "EventName": [...] } }`.
|
||||
/// Like [`parse_plugin_hooks`] for an inline manifest hooks value (no file I/O).
|
||||
pub fn parse_plugin_hooks_from_value(
|
||||
value: &serde_json::Value,
|
||||
plugin_name: &str,
|
||||
|
|
@ -156,9 +99,7 @@ fn process_hooks_content(
|
|||
warnings.push(msg);
|
||||
}
|
||||
|
||||
// Build plugin env vars. `GROK_PLUGIN_*` is the native contract;
|
||||
// `CLAUDE_PLUGIN_*` aliases the same values for external hooks that read
|
||||
// those names.
|
||||
// Native `GROK_PLUGIN_*` vars plus their vendor-compat aliases.
|
||||
let plugin_env: HashMap<String, String> = HashMap::from([
|
||||
("GROK_PLUGIN_ROOT".to_string(), plugin_root.to_string()),
|
||||
("CLAUDE_PLUGIN_ROOT".to_string(), plugin_root.to_string()),
|
||||
|
|
@ -166,37 +107,23 @@ fn process_hooks_content(
|
|||
("CLAUDE_PLUGIN_DATA".to_string(), plugin_data.to_string()),
|
||||
]);
|
||||
|
||||
// Inject env vars and update source labels.
|
||||
//
|
||||
// The plugin adapter owns the keys in `plugin_env` (CLAUDE_PLUGIN_ROOT
|
||||
// etc.), so plugin-injected values must always win over any
|
||||
// user-declared `env` on the hook JSON for those specific keys --
|
||||
// otherwise a plugin author could (deliberately or by accident) pin
|
||||
// the plugin root to an arbitrary path and break the plugin
|
||||
// contract. User-declared keys not owned by the plugin are
|
||||
// preserved.
|
||||
for spec in &mut specs {
|
||||
// Plugin-owned keys always win over user-declared `env`, or a plugin
|
||||
// author could repoint the plugin root and break the contract.
|
||||
for (k, v) in &plugin_env {
|
||||
spec.extra_env.insert(k.clone(), v.clone());
|
||||
}
|
||||
// Prefix name with plugin namespace for identification
|
||||
spec.name = format!("plugin/{}/{}", plugin_name, spec.name);
|
||||
// Substitute plugin env vars in command paths at config-load time so
|
||||
// that hooks like `${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh` resolve to the
|
||||
// real plugin directory regardless of which spawn branch the runner
|
||||
// takes (mirrors what managed_mcp does for MCP server commands).
|
||||
spec.layer = xai_grok_hooks::config::HookProvenance::Plugin;
|
||||
spec.name = format!(
|
||||
"{}{}/{}",
|
||||
xai_grok_hooks::config::PLUGIN_HOOK_PREFIX,
|
||||
plugin_name,
|
||||
spec.name
|
||||
);
|
||||
// Resolve plugin path placeholders at load time (mirrors managed_mcp)
|
||||
// so the command works regardless of the runner's spawn branch.
|
||||
if let Some(cmd) = &spec.command {
|
||||
let cmd_str = cmd.to_string_lossy();
|
||||
// Mirror what `managed_mcp::load_plugin_mcp_servers_from_config`
|
||||
// does for plugin MCP server commands: first substitute the
|
||||
// plugin-specific placeholders (`${CLAUDE_PLUGIN_ROOT}` and
|
||||
// friends), then run the result through the generic
|
||||
// `${VAR}` / `$VAR` env expansion. Doing both passes at
|
||||
// config-load time keeps hook env var resolution consistent
|
||||
// with managed MCP server resolution and avoids relying on
|
||||
// the runtime `sh -c` shell-metachar heuristic in
|
||||
// `xai-grok-hooks::runner::command` for env vars whose
|
||||
// values are already known at load time.
|
||||
let substituted = substitute_env_vars(&cmd_str, plugin_root, plugin_data);
|
||||
let expanded = xai_grok_config::expand_env_vars_in_string(&substituted);
|
||||
if expanded != cmd_str {
|
||||
|
|
@ -208,21 +135,16 @@ fn process_hooks_content(
|
|||
(specs, warnings)
|
||||
}
|
||||
|
||||
/// Pre-filter unsupported event names from a hooks JSON file.
|
||||
///
|
||||
/// Parses the JSON, removes event keys from the `"hooks"` object that are
|
||||
/// not in the supported set, and returns the filtered JSON string plus the
|
||||
/// list of removed event names.
|
||||
///
|
||||
/// This is critical because the hooks crate uses `HashMap<HookEventName, ...>`
|
||||
/// deserialization which causes a full parse failure on unknown event names.
|
||||
/// Drop `hooks` event keys the parser wouldn't accept, returning the filtered
|
||||
/// JSON and the removed names. Not needed for correctness (the parser is lenient)
|
||||
/// but surfaces the drops to the plugin author as warnings. A key is supported
|
||||
/// exactly when [`HookEventName::parse_key`] accepts it, so there is no allowlist
|
||||
/// to drift.
|
||||
fn prefilter_unsupported_events(json_content: &str) -> (String, Vec<String>) {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(json_content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
// If JSON is invalid, return as-is and let parse_hook_file handle the error
|
||||
return (json_content.to_string(), vec![]);
|
||||
}
|
||||
// Invalid JSON: let parse_hook_file report it.
|
||||
Err(_) => return (json_content.to_string(), vec![]),
|
||||
};
|
||||
|
||||
let mut skipped = Vec::new();
|
||||
|
|
@ -230,7 +152,7 @@ fn prefilter_unsupported_events(json_content: &str) -> (String, Vec<String>) {
|
|||
if let Some(hooks_obj) = value.get_mut("hooks").and_then(|v| v.as_object_mut()) {
|
||||
let keys_to_remove: Vec<String> = hooks_obj
|
||||
.keys()
|
||||
.filter(|key| !SUPPORTED_EVENTS.contains(&key.as_str()))
|
||||
.filter(|key| HookEventName::parse_key(key).is_none())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
|
|
@ -424,12 +346,8 @@ mod tests {
|
|||
assert!(warnings.iter().any(|w| w.contains("FutureEvent")));
|
||||
}
|
||||
|
||||
/// Regression: hook commands that reference
|
||||
/// `${CLAUDE_PLUGIN_ROOT}` (or its `GROK_PLUGIN_ROOT` alias) must be
|
||||
/// substituted at config-load time so the runner spawns the real
|
||||
/// plugin path. Without substitution the runner's pre-spawn env-var
|
||||
/// check refuses to run such hooks (the dispatcher fail-opens so the
|
||||
/// tool call itself is not blocked, but the hook never runs).
|
||||
/// Regression: plugin path placeholders must resolve at load time, else the
|
||||
/// runner's pre-spawn env check refuses to run the hook.
|
||||
#[test]
|
||||
fn parse_plugin_hooks_substitutes_plugin_root_in_command() {
|
||||
let value = serde_json::json!({
|
||||
|
|
@ -471,12 +389,8 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
// The plugin adapter must NOT mutate
|
||||
// `command_raw`. The pager UI / ACP DTO surface the raw form
|
||||
// for display so users see what they wrote (and so any secrets
|
||||
// resolved from `extra_env` don't leak). A future "tidy" pass
|
||||
// that mistakenly rewrote `command_raw` would silently break
|
||||
// the secrets-leakage protection.
|
||||
// `command_raw` must stay unmodified: it's the display form and rewriting
|
||||
// it would leak `extra_env`-resolved secrets.
|
||||
let raws: Vec<&str> = specs
|
||||
.iter()
|
||||
.map(|s| s.command_raw.as_deref().unwrap_or(""))
|
||||
|
|
@ -503,22 +417,8 @@ mod tests {
|
|||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
/// Regression: plugin hook commands that reference generic env vars
|
||||
/// (e.g. `${HOME}` / `$HOME`) must be expanded at config-load time
|
||||
/// just like managed MCP server commands. Otherwise resolution
|
||||
/// depends on the runtime `sh -c` heuristic in
|
||||
/// `xai-grok-hooks::runner::command`, which can fail for hooks
|
||||
/// whose handler doesn't otherwise contain shell metacharacters.
|
||||
/// Plugin hooks must not be double-expanded: a `${CLAUDE_PLUGIN_ROOT}`
|
||||
/// reference resolves to the plugin root exactly once, and the result
|
||||
/// contains no leftover `$` placeholders. This is the contract the
|
||||
/// hooks_adapter has long held, and it must continue to hold
|
||||
/// now that `parse_hook_file` itself does an env-expansion pass with
|
||||
/// the per-hook `extra_env`. The first pass (in `parse_hook_file`)
|
||||
/// runs against an EMPTY `extra_env` for plugin hooks (the adapter
|
||||
/// only fills it in afterwards), so the placeholder survives that
|
||||
/// pass and the second pass (here, after `extra_env` is wired in)
|
||||
/// resolves it.
|
||||
/// Regression: generic env vars (`${HOME}`) resolve at load time, and plugin
|
||||
/// placeholders resolve exactly once (no leftover `$`, no double-expansion).
|
||||
#[test]
|
||||
fn parse_plugin_hooks_resolves_plugin_root_exactly_once() {
|
||||
let value = serde_json::json!({
|
||||
|
|
@ -553,17 +453,10 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Plugin hook JSON may declare its own `env` map. The user-declared
|
||||
/// keys land in `extra_env`, but the plugin adapter MUST override
|
||||
/// any user-declared value for keys the plugin owns
|
||||
/// (CLAUDE_PLUGIN_ROOT, GROK_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA,
|
||||
/// GROK_PLUGIN_DATA). This preserves the plugin contract while still
|
||||
/// supporting user-defined env vars on plugin hooks.
|
||||
/// User-declared `env` is kept, but the four plugin-owned keys always win.
|
||||
#[test]
|
||||
fn parse_plugin_hooks_user_env_merged_with_plugin_precedence() {
|
||||
// Exercise ALL FOUR plugin-owned keys, not just
|
||||
// CLAUDE_PLUGIN_ROOT. A regression that only iterates one key
|
||||
// would otherwise pass.
|
||||
// All four keys, so a one-key regression can't pass.
|
||||
let value = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
|
|
@ -601,10 +494,7 @@ mod tests {
|
|||
"user-declared env keys must survive plugin merge"
|
||||
);
|
||||
|
||||
// All four plugin-owned keys: plugin wins, user's attempt is
|
||||
// overridden. CLAUDE_PLUGIN_ROOT and GROK_PLUGIN_ROOT both map
|
||||
// to plugin_root; CLAUDE_PLUGIN_DATA and GROK_PLUGIN_DATA both
|
||||
// map to plugin_data.
|
||||
// All four plugin-owned keys: plugin wins over the user's attempt.
|
||||
for (key, expected) in [
|
||||
("CLAUDE_PLUGIN_ROOT", "/actual/plugin/root"),
|
||||
("GROK_PLUGIN_ROOT", "/actual/plugin/root"),
|
||||
|
|
@ -621,10 +511,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_plugin_hooks_expands_generic_env_vars_in_command() {
|
||||
// SAFETY: only mutated within this single-threaded test.
|
||||
// SAFETY: this test sets process env vars; tokio test macros
|
||||
// serialize tests within the same module by default but to be
|
||||
// robust use a uniquely-named var.
|
||||
// Uniquely-named var so concurrent tests don't collide.
|
||||
let var = "GB1183_HOOKS_ADAPTER_TEST_HOME";
|
||||
// SAFETY: env writes are not thread-safe; this test is single-threaded.
|
||||
unsafe {
|
||||
|
|
|
|||
Loading…
Reference in a new issue