Synced from monorepo
Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
|
|
@ -78,14 +78,18 @@ impl HookRegistry {
|
|||
/// Recompile the `matcher` field on every [`HookSpec`] from its
|
||||
/// `configured_matcher` pattern string.
|
||||
///
|
||||
/// After deserialization, the compiled [`HookMatcher`] is `None`
|
||||
/// (it is `#[serde(skip)]`). This method rebuilds it using the
|
||||
/// same logic as the original parse path, via [`HookMatcher::new`].
|
||||
/// After deserialization the compiled [`HookMatcher`] is `None`
|
||||
/// (`#[serde(skip)]`). This rebuilds it via [`HookMatcher::new`].
|
||||
///
|
||||
/// Specs whose `configured_matcher` is `None` (match-all) are
|
||||
/// left untouched. Invalid patterns are logged and the spec's
|
||||
/// matcher remains `None` (match-all fallback), which is the
|
||||
/// safest fail-open behavior.
|
||||
/// Specs whose `configured_matcher` is `None` (intentional match-all)
|
||||
/// are left untouched. Invalid patterns cannot be rejected the way the
|
||||
/// parse path does (`HookError::InvalidMatcher` + skip the hook): the
|
||||
/// registry is already live, so we install [`HookMatcher::never`]
|
||||
/// instead: fail closed rather than widening to match all.
|
||||
///
|
||||
/// Call this after any serde / wire restore (e.g. workspace proxy
|
||||
/// `wire_to_hook_registry`). Until then, a configured pattern with
|
||||
/// `matcher: None` behaves as match-all.
|
||||
pub fn recompile_matchers(&mut self) {
|
||||
for specs in self.hooks.values_mut() {
|
||||
for spec in specs.iter_mut() {
|
||||
|
|
@ -97,9 +101,10 @@ impl HookRegistry {
|
|||
hook = %spec.name,
|
||||
pattern = %pattern,
|
||||
error = %e,
|
||||
"hooks: failed to recompile matcher after deserialization"
|
||||
"hooks: hook will match no tools until its matcher pattern is fixed"
|
||||
);
|
||||
// Leave matcher as None → match-all (fail-open).
|
||||
// Fail closed: invalid matcher must not match-all.
|
||||
spec.matcher = Some(HookMatcher::never());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -889,4 +894,103 @@ mod tests {
|
|||
assert!(errors.is_empty(), "errors: {errors:?}");
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
/// Wire/serde-shaped spec: compiled matcher cleared, pattern still set.
|
||||
fn recompile_test_spec(
|
||||
name: &str,
|
||||
configured_matcher: Option<&str>,
|
||||
) -> crate::config::HookSpec {
|
||||
use std::path::PathBuf;
|
||||
crate::config::HookSpec {
|
||||
name: name.into(),
|
||||
event: HookEventName::PreToolUse,
|
||||
handler_type: "command".into(),
|
||||
configured_matcher: configured_matcher.map(str::to_owned),
|
||||
matcher: None,
|
||||
enabled: true,
|
||||
command: Some(PathBuf::from("hook.sh")),
|
||||
command_raw: Some("hook.sh".into()),
|
||||
url: None,
|
||||
url_raw: None,
|
||||
timeout_ms: 5_000,
|
||||
source_dir: PathBuf::from("/tmp"),
|
||||
extra_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recompile_matchers_fail_closed_on_invalid_pattern() {
|
||||
// Serde skips `matcher`; recompile must not leave it None (match-all).
|
||||
let mut registry = HookRegistry::default();
|
||||
registry.append_specs(vec![recompile_test_spec("broken", Some("[invalid"))]);
|
||||
registry.recompile_matchers();
|
||||
|
||||
let hooks = registry.hooks_for(HookEventName::PreToolUse);
|
||||
assert_eq!(hooks.len(), 1);
|
||||
let matcher = hooks[0]
|
||||
.matcher
|
||||
.as_ref()
|
||||
.expect("invalid matcher must compile to never-match, not stay None");
|
||||
assert!(!matcher.is_match("run_terminal_command"));
|
||||
assert!(!matcher.is_match("read_file"));
|
||||
assert!(!matcher.is_match("Bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recompile_matchers_restores_valid_pattern() {
|
||||
let mut registry = HookRegistry::default();
|
||||
registry.append_specs(vec![recompile_test_spec("ok", Some("Bash"))]);
|
||||
registry.recompile_matchers();
|
||||
|
||||
let matcher = registry.hooks_for(HookEventName::PreToolUse)[0]
|
||||
.matcher
|
||||
.as_ref()
|
||||
.expect("valid matcher should recompile");
|
||||
assert!(matcher.is_match("run_terminal_command"));
|
||||
assert!(!matcher.is_match("read_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recompile_matchers_leaves_intentional_match_all() {
|
||||
let mut registry = HookRegistry::default();
|
||||
registry.append_specs(vec![recompile_test_spec("all", None)]);
|
||||
registry.recompile_matchers();
|
||||
|
||||
assert!(
|
||||
registry.hooks_for(HookEventName::PreToolUse)[0]
|
||||
.matcher
|
||||
.is_none(),
|
||||
"no configured pattern must stay match-all (matcher None)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recompile_matchers_isolates_invalid_sibling() {
|
||||
let mut registry = HookRegistry::default();
|
||||
registry.append_specs(vec![
|
||||
recompile_test_spec("ok", Some("Bash")),
|
||||
recompile_test_spec("broken", Some("[invalid")),
|
||||
]);
|
||||
registry.recompile_matchers();
|
||||
|
||||
let hooks = registry.hooks_for(HookEventName::PreToolUse);
|
||||
assert_eq!(hooks.len(), 2);
|
||||
let by_name: std::collections::HashMap<_, _> =
|
||||
hooks.iter().map(|h| (h.name.as_str(), h)).collect();
|
||||
|
||||
let ok = by_name["ok"]
|
||||
.matcher
|
||||
.as_ref()
|
||||
.expect("valid sibling must recompile");
|
||||
assert!(ok.is_match("run_terminal_command"));
|
||||
assert!(!ok.is_match("read_file"));
|
||||
|
||||
let broken = by_name["broken"]
|
||||
.matcher
|
||||
.as_ref()
|
||||
.expect("invalid sibling must become never-match");
|
||||
assert!(!broken.is_match("run_terminal_command"));
|
||||
assert!(!broken.is_match("Bash"));
|
||||
assert!(!broken.is_match("read_file"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ pub struct HookMatcher {
|
|||
#[derive(Debug, Clone)]
|
||||
enum MatcherKind {
|
||||
All,
|
||||
/// Matches no tool names. Used when a configured matcher fails to compile
|
||||
/// after deserialization — fail closed rather than widen to match-all.
|
||||
Never,
|
||||
Exact(Vec<String>),
|
||||
Regex(Regex),
|
||||
}
|
||||
|
|
@ -39,9 +42,18 @@ impl HookMatcher {
|
|||
Ok(Self { kind })
|
||||
}
|
||||
|
||||
/// Matcher that never matches. Prefer this over `None` on a [`HookSpec`] when a
|
||||
/// pattern was configured but could not be compiled (fail-closed).
|
||||
pub(crate) fn never() -> Self {
|
||||
Self {
|
||||
kind: MatcherKind::Never,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_match(&self, tool_name: &str) -> bool {
|
||||
match &self.kind {
|
||||
MatcherKind::All => true,
|
||||
MatcherKind::Never => false,
|
||||
MatcherKind::Exact(names) => names.iter().any(|n| n == tool_name),
|
||||
MatcherKind::Regex(regex) => {
|
||||
regex.is_match(tool_name)
|
||||
|
|
@ -134,6 +146,15 @@ mod tests {
|
|||
assert!(HookMatcher::new("[invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_matches_nothing() {
|
||||
let m = HookMatcher::never();
|
||||
assert!(!m.is_match("read_file"));
|
||||
assert!(!m.is_match("run_terminal_command"));
|
||||
assert!(!m.is_match(""));
|
||||
assert!(!m.is_match("*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_and_empty_match_all() {
|
||||
for pat in ["*", ""] {
|
||||
|
|
|
|||
Loading…
Reference in a new issue