aboutsummaryrefslogtreecommitdiff
path: root/src/detect
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 01:53:55 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-17 01:53:55 +0300
commit99760f77db74d42108865cc412a47137b0ed384c (patch)
tree44e540178d6d267b741c13c07bb17ce516965640 /src/detect
parentb42c6e661e5b02952f8990d9a16dce984e41463a (diff)
fix: detect grok background work from pinned status
Diffstat (limited to 'src/detect')
-rw-r--r--src/detect/manifest.rs49
-rw-r--r--src/detect/manifest/tests.rs47
-rw-r--r--src/detect/manifest_update.rs2
-rw-r--r--src/detect/manifests/grok.toml15
4 files changed, 109 insertions, 4 deletions
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs
index 736be823..ed3de507 100644
--- a/src/detect/manifest.rs
+++ b/src/detect/manifest.rs
@@ -922,6 +922,16 @@ fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> {
}
validate_region_name(&rule.region)
.map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?;
+ if rule.region.trim().starts_with("top_non_empty_lines(")
+ && manifest
+ .min_engine_version
+ .is_some_and(|version| version < TOP_NON_EMPTY_LINES_ENGINE_VERSION)
+ {
+ return Err(format!(
+ "rule {} uses top_non_empty_lines but min_engine_version is below {}",
+ rule.id, TOP_NON_EMPTY_LINES_ENGINE_VERSION
+ ));
+ }
validate_rule_gate(rule, &mut complexity)
.map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?;
}
@@ -1075,7 +1085,8 @@ fn validate_region_name(spec: &str) -> Result<(), String> {
| "osc_title"
| "osc_progress" => Ok(()),
_ if region_count(trimmed, "bottom_lines").is_some()
- || region_count(trimmed, "bottom_non_empty_lines").is_some() =>
+ || region_count(trimmed, "bottom_non_empty_lines").is_some()
+ || top_region_count(trimmed).is_some() =>
{
Ok(())
}
@@ -1272,6 +1283,9 @@ fn region<'a>(input: DetectionInput<'a>, spec: &str) -> &'a str {
if let Some(count) = region_count(trimmed, "bottom_non_empty_lines") {
return bottom_non_empty_lines(content, count);
}
+ if let Some(count) = top_region_count(trimmed) {
+ return top_non_empty_lines(content, count);
+ }
""
}
}
@@ -1284,6 +1298,23 @@ fn region_count(spec: &str, name: &str) -> Option<usize> {
.and_then(|count| count.parse::<usize>().ok())
}
+const TOP_NON_EMPTY_LINES_ENGINE_VERSION: u32 = 3;
+const MAX_TOP_REGION_LINE_COUNT: usize = u16::MAX as usize;
+
+fn top_region_count(spec: &str) -> Option<usize> {
+ let count = spec
+ .strip_prefix("top_non_empty_lines")?
+ .strip_prefix('(')?
+ .strip_suffix(')')?;
+ if count.starts_with('0') || !count.bytes().all(|byte| byte.is_ascii_digit()) {
+ return None;
+ }
+ count
+ .parse::<usize>()
+ .ok()
+ .filter(|count| *count <= MAX_TOP_REGION_LINE_COUNT)
+}
+
fn bottom_lines(content: &str, count: usize) -> &str {
let lines: Vec<&str> = content.lines().collect();
let start = lines.len().saturating_sub(count);
@@ -1306,6 +1337,22 @@ fn bottom_non_empty_lines(content: &str, count: usize) -> &str {
slice_from_line_index(content, &lines, start_index)
}
+fn top_non_empty_lines(content: &str, count: usize) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(end_index) = lines
+ .iter()
+ .enumerate()
+ .filter(|(_, line)| !line.trim().is_empty())
+ .take(count)
+ .last()
+ .map(|(index, _)| index)
+ else {
+ return "";
+ };
+ let byte_offset = line_start_offset(content, &lines, end_index + 1);
+ &content[..byte_offset]
+}
+
fn after_last_prompt_marker(content: &str) -> &str {
let lines: Vec<&str> = content.lines().collect();
let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else {
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
index 12eb1235..ec1873f7 100644
--- a/src/detect/manifest/tests.rs
+++ b/src/detect/manifest/tests.rs
@@ -543,6 +543,53 @@ fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() {
);
}
+#[test]
+fn top_non_empty_lines_uses_top_occurrence_for_repeated_text() {
+ let content = "\nmarker\nold\n\nmiddle\nmarker\nnew\n";
+
+ assert_eq!(
+ region(
+ DetectionInput {
+ screen: content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ "top_non_empty_lines(2)"
+ ),
+ "\nmarker\nold\n"
+ );
+}
+
+#[test]
+fn top_non_empty_lines_requires_a_canonical_positive_bounded_count() {
+ let name = "top_non_empty_lines";
+ assert!(validate_region_name(&format!("{name}(1)")).is_ok());
+ assert!(validate_region_name(&format!("{name}({})", u16::MAX)).is_ok());
+ for count in ["0", "01", "+1", "65536", "999999999999999999999999"] {
+ assert!(
+ validate_region_name(&format!("{name}({count})")).is_err(),
+ "{name} accepted invalid count {count}"
+ );
+ }
+}
+
+#[test]
+fn top_non_empty_lines_requires_engine_three_when_declared() {
+ let manifest = r#"
+id = "grok"
+version = "1"
+min_engine_version = 2
+
+[[rules]]
+id = "background"
+state = "working"
+region = " top_non_empty_lines(1) "
+contains = ["active"]
+"#;
+
+ assert!(parse_manifest(manifest).is_err());
+}
+
// ---------------------------------------------------------------------------
// OSC rule tests — exercise the new osc_title / osc_progress regions against
// the bundled Claude and Codex manifests.
diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs
index 8c5fc339..362dd906 100644
--- a/src/detect/manifest_update.rs
+++ b/src/detect/manifest_update.rs
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use super::{agent_label, parse_agent_label, Agent};
-pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 2;
+pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 3;
const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml";
const CATALOG_URL_ENV: &str = "HERDR_AGENT_DETECTION_MANIFEST_CATALOG_URL";
const MAX_FETCH_BYTES: usize = 256 * 1024;
diff --git a/src/detect/manifests/grok.toml b/src/detect/manifests/grok.toml
index 996cf377..30b6e9c7 100644
--- a/src/detect/manifests/grok.toml
+++ b/src/detect/manifests/grok.toml
@@ -1,6 +1,6 @@
id = "grok"
-version = "2026.07.16.1"
-min_engine_version = 2
+version = "2026.07.16.2"
+min_engine_version = 3
updated_at = "2026-07-16T00:00:00Z"
aliases = ["grok-build"]
@@ -74,6 +74,17 @@ any = [
{ contains = ["←/→:scope"] },
]
+# Grok clears its OSC busy signals while background work runs. The first
+# non-empty row is pinned application chrome, where this animated chip shows
+# the number of running background tasks and disappears when the count is zero.
+[[rules]]
+id = "background_work_chip_working"
+state = "working"
+priority = 1170
+region = "top_non_empty_lines(1)"
+visible_working = true
+line_regex = ['[⋅:⸬⁙.·]\s+[1-9][0-9]*\s+│']
+
[[rules]]
id = "osc_progress_working"
state = "working"