aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scripts/agent_detection_manifest_check.py39
-rw-r--r--scripts/test_agent_detection_manifest_check.py87
-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
6 files changed, 233 insertions, 6 deletions
diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py
index d1346a94..f180318b 100644
--- a/scripts/agent_detection_manifest_check.py
+++ b/scripts/agent_detection_manifest_check.py
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
+import hashlib
import re
import sys
import tomllib
@@ -39,9 +40,12 @@ REGION_RE = re.compile(
r"before_current_prompt_marker|current_prompt_block_marker|after_current_prompt_block_marker|"
r"prompt_box_body|above_prompt_box|last_non_empty_above_prompt_box|after_last_horizontal_rule|"
r"osc_title|osc_progress|"
- r"bottom_lines\([1-9][0-9]*\)|bottom_non_empty_lines\([1-9][0-9]*\))$"
+ r"bottom_lines\([1-9][0-9]*\)|bottom_non_empty_lines\([1-9][0-9]*\)|"
+ r"top_non_empty_lines\([1-9][0-9]*\))$"
)
+REGION_COUNT_RE = re.compile(r"\(([1-9][0-9]*)\)$")
VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)*$")
+MAX_TOP_REGION_LINE_COUNT = 65_535
MAX_RULES_PER_MANIFEST = 128
MAX_GATE_DEPTH = 8
MAX_TOTAL_GATES = 512
@@ -49,6 +53,17 @@ MAX_MATCHERS_PER_GATE = 32
MAX_TOTAL_MATCHERS = 1024
MAX_MATCHER_CHARS = 512
+# Keep engine-2 clients on the OSC-capable manifest until an engine-3 release
+# can consume top_non_empty_lines. Remove this entry when the website publishes
+# the bundled Grok manifest.
+STAGED_WEBSITE_MANIFESTS = {
+ "grok": (
+ "2026.07.16.2",
+ "2026.07.16.1",
+ "1f35b3271a96cf830c64bed78751619bfd8013c277c0d7c0f999b7a433895f28",
+ ),
+}
+
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
@@ -136,6 +151,11 @@ def validate_manifest(path: Path, engine_version: int) -> dict:
complexity = {"gates": 0, "matchers": 0}
for index, rule in enumerate(rules):
validate_rule(path, index, rule, complexity)
+ region = rule.get("region", "whole_recent")
+ if region.startswith("top_non_empty_lines(") and min_engine < 3:
+ raise CheckError(
+ f"{path}: rule {rule['id']} region {region!r} requires min_engine_version 3"
+ )
return manifest
@@ -155,6 +175,13 @@ def validate_rule(path: Path, index: int, rule: object, complexity: dict[str, in
region = rule.get("region", "whole_recent")
if not isinstance(region, str) or not REGION_RE.fullmatch(region):
raise CheckError(f"{path}: rule {rule_id} has invalid region {region!r}")
+ count_match = REGION_COUNT_RE.search(region)
+ if (
+ region.startswith("top_non_empty_lines(")
+ and count_match
+ and int(count_match.group(1)) > MAX_TOP_REGION_LINE_COUNT
+ ):
+ raise CheckError(f"{path}: rule {rule_id} has invalid region {region!r}")
if rule.get("skip_state_update"):
if state != "unknown":
raise CheckError(f"{path}: rule {rule_id} skip_state_update requires state unknown")
@@ -295,7 +322,15 @@ def validate_catalog(
bundled_path, bundled_manifest = bundled[agent_id]
cmp = compare_versions(manifest["version"], bundled_manifest["version"], manifest_path)
- if cmp < 0:
+ staged_manifest = STAGED_WEBSITE_MANIFESTS.get(agent_id)
+ website_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
+ stages_new_engine_manifest = (
+ staged_manifest
+ == (bundled_manifest["version"], manifest["version"], website_digest)
+ and bundled_manifest["min_engine_version"] == engine_version
+ and manifest["min_engine_version"] < bundled_manifest["min_engine_version"]
+ )
+ if cmp < 0 and not stages_new_engine_manifest:
raise CheckError(
f"{manifest_path}: website version {manifest['version']} is lower than bundled "
f"{bundled_manifest['version']} in {bundled_path}"
diff --git a/scripts/test_agent_detection_manifest_check.py b/scripts/test_agent_detection_manifest_check.py
index 3ab69b55..a9ec38d1 100644
--- a/scripts/test_agent_detection_manifest_check.py
+++ b/scripts/test_agent_detection_manifest_check.py
@@ -27,6 +27,21 @@ path = "{path}"
'''
+def staged_grok_dirs(root: Path) -> tuple[Path, Path]:
+ bundled = root / "bundled"
+ website = root / "website"
+ bundled.mkdir()
+ website.mkdir()
+ (bundled / "grok.toml").write_bytes(
+ (check.DEFAULT_BUNDLED_DIR / "grok.toml").read_bytes()
+ )
+ (website / "grok.toml").write_bytes(
+ (check.DEFAULT_WEBSITE_DIR / "grok.toml").read_bytes()
+ )
+ (website / "index.toml").write_text(catalog("grok", "grok.toml"))
+ return bundled, website
+
+
class AgentDetectionManifestCheckTests(unittest.TestCase):
def test_validates_bundled_and_matching_website_catalog(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -58,6 +73,41 @@ class AgentDetectionManifestCheckTests(unittest.TestCase):
with self.assertRaisesRegex(check.CheckError, "lower than bundled"):
check.validate_catalog(website, bundled_manifests, engine_version=1)
+ def test_allows_explicitly_staged_website_manifest(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ bundled, website = staged_grok_dirs(Path(tmp))
+
+ bundled_manifests = check.load_manifest_dir(bundled, engine_version=3)
+ check.validate_catalog(website, bundled_manifests, engine_version=3)
+
+ def test_rejects_mutated_staged_website_manifest(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ bundled, website = staged_grok_dirs(Path(tmp))
+ with (website / "grok.toml").open("a") as manifest_file:
+ manifest_file.write("\n# unexpected mutation\n")
+
+ bundled_manifests = check.load_manifest_dir(bundled, engine_version=3)
+ with self.assertRaisesRegex(check.CheckError, "lower than bundled"):
+ check.validate_catalog(website, bundled_manifests, engine_version=3)
+
+ def test_rejects_unlisted_website_manifest_lag_for_new_engine(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ bundled = root / "bundled"
+ website = root / "website"
+ bundled.mkdir()
+ website.mkdir()
+ bundled_content = manifest("codex", "2026.06.10.2").replace(
+ "min_engine_version = 1", "min_engine_version = 2"
+ )
+ (bundled / "codex.toml").write_text(bundled_content)
+ (website / "codex.toml").write_text(manifest("codex", "2026.06.10.1"))
+ (website / "index.toml").write_text(catalog())
+
+ bundled_manifests = check.load_manifest_dir(bundled, engine_version=2)
+ with self.assertRaisesRegex(check.CheckError, "lower than bundled"):
+ check.validate_catalog(website, bundled_manifests, engine_version=2)
+
def test_rejects_same_version_content_drift(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -101,6 +151,43 @@ class AgentDetectionManifestCheckTests(unittest.TestCase):
with self.assertRaisesRegex(check.CheckError, "exceeds engine"):
check.load_manifest_dir(bundled, engine_version=1)
+ def test_rejects_top_non_empty_lines_below_engine_three(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ bundled = Path(tmp) / "bundled"
+ bundled.mkdir()
+ content = manifest("codex", "2026.06.10.1").replace(
+ 'contains = ["ready"]',
+ 'region = "top_non_empty_lines(1)"\ncontains = ["ready"]',
+ )
+ (bundled / "codex.toml").write_text(content)
+
+ with self.assertRaisesRegex(check.CheckError, "requires min_engine_version 3"):
+ check.load_manifest_dir(bundled, engine_version=3)
+
+ def test_top_non_empty_lines_requires_canonical_positive_bounded_count(self):
+ base_rule = {
+ "id": "test",
+ "state": "working",
+ "contains": ["ready"],
+ }
+ name = "top_non_empty_lines"
+ for count in ("1", str(check.MAX_TOP_REGION_LINE_COUNT)):
+ rule = {**base_rule, "region": f"{name}({count})"}
+ check.validate_rule(Path("test.toml"), 0, rule, {"gates": 0, "matchers": 0})
+ for count in (
+ "0",
+ "01",
+ "+1",
+ str(check.MAX_TOP_REGION_LINE_COUNT + 1),
+ "9" * 40,
+ ):
+ rule = {**base_rule, "region": f"{name}({count})"}
+ with self.subTest(region=rule["region"]):
+ with self.assertRaisesRegex(check.CheckError, "invalid region"):
+ check.validate_rule(
+ Path("test.toml"), 0, rule, {"gates": 0, "matchers": 0}
+ )
+
if __name__ == "__main__":
unittest.main()
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"