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:
grokkybara[bot] 2026-07-17 14:19:50 +01:00
commit 98c3b2438a
225 changed files with 18836 additions and 7156 deletions

View file

@ -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"));
}
}

View 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 ["*", ""] {