diff --git a/DESIGN.md b/DESIGN.md index 661c739..70842e4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -621,6 +621,7 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar ```json { "fingerprint": "service|class|stack-sig|env", + "template_fingerprint": "service|class|template-sig|env", "service": "api", "environment": "prod", "class": "TimeoutError", @@ -634,6 +635,17 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar } ``` +`template_fingerprint` is `fingerprint` one step coarser: known blockchain and +payment-rail names and asset tickers are masked before hashing, so per-chain and +per-token variants of one error share it. Names are masked only as whole tokens +and case-sensitively, so prose like "Based" or lowercase "usd" is not mistaken +for a chain or a ticker. `service`, `class` and `environment` are +percent-escaped (`%`→`%25`, `|`→`%7C`) before the join, so two different field +tuples cannot serialize to one fingerprint. They keep their raw values: +grouping has to stay injective, or two tenants whose labels merely look alike +after redaction would be grouped as one. `fingerprint` stays the finer-grained +identity used for `count` / `last_seen`, so per-variant dedup remains exact. + `repo` may be omitted when the adapter cannot map the stream; the session then `error.skip`s with reason `unmapped-repo`. `line_fingerprint` is optional: `sha256(server + newline + container + newline + exact line)` as 64 lowercase hex, computed from the raw line before redaction. Omit it when `server` or `container` is missing. Host adapters may print the hex on `error.fix` stdout; it is not a mandate and not a log-host name. ### 21.4 Analysis and eligibility diff --git a/src/agent_cli/errors.py b/src/agent_cli/errors.py index 85d1147..5a1cebb 100644 --- a/src/agent_cli/errors.py +++ b/src/agent_cli/errors.py @@ -42,6 +42,88 @@ _UUID = re.compile( r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" ) +# The blockchain and payment-rail names the platform already exposes as transfer +# options, current as of 2026-08-31 — masked in the template signature so a +# per-chain error variant ("Timeout updating balances for Ethereum" vs "...for +# Polygon") groups under one coarser template instead of fragmenting one +# fingerprint per chain. Refresh when that list changes; there is no automated sync. +_KNOWN_CHAINS = frozenset( + { + "DeFiChain", "Ethereum", "Arbitrum", "Polygon", "BinanceSmartChain", "Binance", + "Kraken", "Base", "Optimism", "Citrea", "MEXC", "XT", "Railgun", "Sumixx", + "InternetComputer", "Scrypt", "Sepolia", "MaerkiBaumann", "Checkout", "Kaleido", + "Solana", "Zano", "Frick", "Tron", "Yapeal", "Talium", "OlkyFrozen", "Haqq", + "Bitcoin", "CitreaTestnet", "Gnosis", "KucoinPay", "Spark", "Lightning", "Firo", + "Olkypay", "BitcoinTestnet4", "BinancePay", "Arkade", "Cardano", "Monero", + } +) + + +def _token_pattern(names: frozenset[str]) -> re.Pattern[str]: + """Alternation over known names, longest-first, anchored on both sides. + + The anchors alone settle names made only of word characters: "Bitcoin" + inside "BitcoinTestnet4" fails its own trailing look-ahead, so the engine + backtracks to the longer alternative whatever the order. Ordering is what + settles the rest — "USDC" inside "USDC.e" is followed by ".", which is not a + word character, so the short alternative would match and strip the ticker + down to the wrong asset unless the longer one is tried first. + + Anchored on both sides with a word-character look-around so a name only + matches as a whole token. That class is Unicode-aware, so a ticker glued to + non-Latin letters or to an underscore is not a ticker either: + without that, "Base" matches inside "Based", "SOL" inside "RESOLVE", "COMP" + inside "COMPLETE" and "DAI" inside "DAILY", which would mask unrelated words + and label an unrelated error as a chain/asset variant. The anchors are + explicit look-arounds rather than \b because several names are not + word-character-only ("USDC.e"). Matching stays case-sensitive on purpose: + lowercase prose words like "usd" in "token tether -> usd" are not tickers.""" + ordered = sorted(names, key=len, reverse=True) + alternatives: list[str] = [] + for name in ordered: + # A shorter name that merely prefixes a longer one is normally settled by + # trying the longer one first. That breaks when the longer one continues + # with a non-word character: "USDC.e" glued to more text fails its own + # trailing boundary, and the engine falls back to "USDC", whose boundary + # passes because "." is not a word character. Block those continuations + # so the short name loses too, exactly as it would inside "USDC_balance". + blockers = "".join( + f"(?!{re.escape(longer[len(name):])})" + for longer in ordered + if longer.startswith(name) + and len(longer) > len(name) + # Word characters, not just alphanumerics: an underscore continuation + # is already handled by the trailing boundary below, so the predicate + # has to mean the same thing that boundary does. + and re.match(r"\w", longer[len(name)]) is None + ) + alternatives.append(re.escape(name) + blockers) + return re.compile(rf"(? str: return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] +def template_signature(line: str) -> str: + """Coarser than stack_sig: also masks known blockchain and payment-rail + names and asset tickers (see _KNOWN_CHAINS/_KNOWN_ASSETS), so a per-chain or + per-token error variant groups under one coarser template instead of + fragmenting into one fingerprint per chain/token pair. error.seen identity + keeps using the finer-grained fingerprint()/stack_sig() so per-variant + count/last_seen tracking stays exact.""" + norm = redact(strip_ansi(line)) + norm = _UUID.sub("", norm) + norm = _CHAIN_TOKEN.sub("", norm) + norm = _ASSET_TOKEN.sub("", norm) + norm = _DIGITS.sub("", norm) + norm = _SPACE.sub(" ", norm).strip().lower() + return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] + + +def _escape_field(value: str) -> str: + """Make a field safe to join with "|". + + service, error_class and environment are free text from the log source, so + an unescaped join is ambiguous: service="a", error_class="b|c" and + service="a|b", error_class="c" would produce the same fingerprint and group + two unrelated errors under one template. Percent-escaping "%" first and then + "|" is reversible, so distinct field tuples stay distinct.""" + return value.replace("%", "%25").replace("|", "%7C") + + +def template_fingerprint( + *, service: str, error_class: str, template_sig: str, environment: str +) -> str: + return "|".join( + ( + _escape_field(service), + _escape_field(error_class), + template_sig, + _escape_field(environment), + ) + ) + + +def known_chain_in(line: str) -> str | None: + """The first known chain or payment-rail name present in the line, if any. + _KNOWN_CHAINS covers both, since the platform exposes them as one set of + transfer options. Most error lines name neither; those return None.""" + match = _CHAIN_TOKEN.search(line) + return match.group(0) if match is not None else None + + +def known_asset_in(line: str) -> str | None: + """The first known asset ticker present in the line, if any — same purpose + as known_chain_in, for the token half of a chain/token variant label.""" + match = _ASSET_TOKEN.search(line) + return match.group(0) if match is not None else None + + def _strip(row: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in row.items() if not k.startswith("_")} @@ -472,6 +609,12 @@ def _apply_lines( stack_sig=stack_sig(redacted), environment=environment, ) + template_fp = template_fingerprint( + service=service, + error_class=cls, + template_sig=template_signature(redacted), + environment=environment, + ) server = item.get("server") container = item.get("container") line_fp = None @@ -490,6 +633,7 @@ def _apply_lines( payload_obj["count"] = count + 1 payload_obj["last_seen"] = ts payload_obj["excerpt"] = excerpt + payload_obj["template_fingerprint"] = template_fp if line_fp is not None: payload_obj["line_fingerprint"] = line_fp else: @@ -503,6 +647,7 @@ def _apply_lines( aid = str(uuid.uuid4()) payload_obj = { "fingerprint": fp, + "template_fingerprint": template_fp, "service": service, "environment": environment, "class": cls, diff --git a/tests/test_errors.py b/tests/test_errors.py index 04b0b6c..e0f2ab7 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -12,11 +12,15 @@ error_class, fingerprint, is_incident_line, + known_asset_in, + known_chain_in, line_fingerprint, load_config, redact, scan_errors, stack_sig, + template_fingerprint, + template_signature, ) from agent_cli.store import Store, StoreError @@ -35,9 +39,16 @@ def _runner_session(store: Store, sid: str = "runner-1") -> None: ) -def _write_config(home: Path, session_id: str = "runner-1") -> None: +def _write_config(home: Path, session_id: str = "runner-1", service: str = "api") -> None: config_path(home).write_text( - json.dumps({"session_id": session_id, "service": "api", "environment": "prod", "repo": "org/app"}), + json.dumps( + { + "session_id": session_id, + "service": service, + "environment": "prod", + "repo": "org/app", + } + ), encoding="utf-8", ) @@ -170,6 +181,64 @@ def test_redact_and_fingerprint() -> None: assert fp.endswith("|prod") +def test_template_signature_masks_known_chains() -> None: + ethereum = "Timeout updating balances for Ethereum: Error: Timeout" + polygon = "Timeout updating balances for Polygon: Error: Timeout" + assert template_signature(ethereum) == template_signature(polygon) + # stack_sig stays fine-grained: chain name is not masked there, so the two + # lines keep separate error.seen identity even though they share a template. + assert stack_sig(ethereum) != stack_sig(polygon) + + +def test_template_signature_masks_known_assets() -> None: + usdc = "Balance for Arbitrum/USDC went low" + wbtc = "Balance for Arbitrum/WBTC went low" + assert template_signature(usdc) == template_signature(wbtc) + assert stack_sig(usdc) != stack_sig(wbtc) + + +def test_asset_token_regex_masks_longest_match_first() -> None: + from agent_cli.errors import _ASSET_TOKEN + + # "USD" is a literal prefix of "USDC" — an unsorted alternation would match + # "USD" first and leave "C" dangling in the masked output. + assert _ASSET_TOKEN.sub("", "balance in USDC today") == "balance in today" + assert _ASSET_TOKEN.sub("", "balance in USD today") == "balance in today" + + +def test_known_asset_in_finds_and_omits() -> None: + assert known_asset_in("Balance for Arbitrum/USDC went low") == "USDC" + assert known_asset_in("Failed to get price for token tether -> usd") is None + + +def test_chain_token_regex_masks_longest_match_first() -> None: + from agent_cli.errors import _CHAIN_TOKEN + + # "Bitcoin" is a prefix of "BitcoinTestnet4" — an unsorted alternation would + # match "Bitcoin" first and leave "Testnet4" dangling in the masked output. + assert _CHAIN_TOKEN.sub("", "check failed for BitcoinTestnet4 today") == ( + "check failed for today" + ) + assert _CHAIN_TOKEN.sub("", "check failed for Bitcoin today") == ( + "check failed for today" + ) + + +def test_known_chain_in_finds_and_omits() -> None: + assert known_chain_in("Timeout updating balances for Ethereum") == "Ethereum" + assert known_chain_in("balance check failed for BitcoinTestnet4") == "BitcoinTestnet4" + assert known_chain_in("Failed to check Bank Frick order status") == "Frick" + assert known_chain_in("Failed to get price for token tether -> usd") is None + + +def test_template_fingerprint_format() -> None: + sig = template_signature("Timeout updating balances for Ethereum") + tfp = template_fingerprint( + service="api", error_class="error", template_sig=sig, environment="prod" + ) + assert tfp == f"api|error|{sig}|prod" + + def test_scan_inserts_once_then_enriches(tmp_path: Path) -> None: store = Store(tmp_path) _runner_session(store) @@ -195,6 +264,7 @@ def fetch(_cfg: dict, _cursor: str | None) -> tuple[list[dict], str | None]: assert payload["evidence"] is None assert "SECRETTOKENVALUE0123456789" not in payload["excerpt"] assert "fingerprint" in payload + assert "template_fingerprint" in payload wakes = store.pending_wakes() assert any(w["activity_id"] == created[0] for w in wakes) @@ -204,6 +274,7 @@ def fetch(_cfg: dict, _cursor: str | None) -> tuple[list[dict], str | None]: again = store.row("activity", created[0]) assert again is not None assert again["payload"]["count"] == 2 + assert again["payload"]["template_fingerprint"] == payload["template_fingerprint"] assert len([w for w in store.pending_wakes() if w["activity_id"] == created[0]]) == 1 assert "line_fingerprint" not in payload @@ -876,3 +947,122 @@ def fetch(_cfg: dict, _cursor: str | None) -> tuple[list[dict], str | None]: assert enriched == [] assert len(created) == 1 assert "keep" in store.row("activity", created[0])["payload"]["excerpt"] + + +def test_token_masking_only_matches_whole_tokens() -> None: + """Without boundary anchors "Base" matches inside "Based", "SOL" inside + "RESOLVE" and "COMP" inside "COMPLETE" — unrelated errors would then be + masked as chain/asset variants and labelled with a token that has nothing to + do with them.""" + for line in ( + "Based on the previous failure the job aborted", + "RESOLVE failed for host", + "COMPLETE checkout failed", + "DAILY reconciliation failed", + "UNIQUE constraint violated on table users", + "POLICY denied the request", + "BATCH job failed", + "MANAGEMENT api unreachable", + "LINKING accounts failed", + "SANDBOX unavailable", + ): + assert known_chain_in(line) is None, line + assert known_asset_in(line) is None, line + + +def test_token_masking_still_matches_real_names_next_to_punctuation() -> None: + assert known_chain_in("Balance for Arbitrum/USDC went low") == "Arbitrum" + assert known_asset_in("Balance for Arbitrum/USDC went low") == "USDC" + # Tickers that are not word-character-only, or start with a digit, still match. + assert known_asset_in("USDC.e drift on Arbitrum") == "USDC.e" + assert known_asset_in("low balance 1INCH on Ethereum") == "1INCH" + + +def test_template_signature_does_not_group_unrelated_words_with_assets() -> None: + # "UNIQUE" must not collapse onto the same template as a real ticker just + # because "UNI" is a prefix of it. + assert template_signature("UNIQUE constraint failed") != template_signature( + "UNI constraint failed" + ) + + +def test_template_fingerprint_fields_cannot_collide() -> None: + """service, error_class and environment are free text from the log source. + An unescaped join would let two different field tuples produce one + fingerprint and group unrelated errors under a single template.""" + first = template_fingerprint( + service="a", error_class="b|c", template_sig="sig", environment="e" + ) + second = template_fingerprint( + service="a|b", error_class="c", template_sig="sig", environment="e" + ) + assert first != second + + # The escape itself must not become a new collision route. + assert template_fingerprint( + service="a%7Cb", error_class="c", template_sig="sig", environment="e" + ) != second + + +def test_asset_alternation_must_try_the_longest_name_first() -> None: + """The boundary anchors settle word-character names on their own, but not a + ticker containing punctuation: "." is not a word character, so "USDC" would + match inside "USDC.e" and strip it down to the wrong asset.""" + assert known_asset_in("USDC.e drift on Arbitrum") == "USDC.e" + assert known_asset_in("USDC drift on Arbitrum") == "USDC" + + +def test_token_boundaries_are_unicode_aware() -> None: + """An ASCII-only boundary class would let a ticker glued to non-Latin letters + or to an underscore still count as a whole token.""" + assert known_asset_in("ЖUSDCб drift") is None + assert known_asset_in("USDC_balance drift") is None + assert known_chain_in("Ethereumб handler") is None + # Real names next to ordinary punctuation still match. + assert known_asset_in("balance for USDC, low") == "USDC" + + +def test_a_prefix_name_does_not_survive_its_longer_form_losing_the_boundary() -> None: + """"USDC.e" glued to a word character fails its own trailing boundary. Without + blocking the continuation the engine falls back to "USDC", whose boundary + passes because "." is not a word character — so the same glued text would + yield a ticker here but None in "USDC_balance".""" + assert known_asset_in("USDC_balance") is None + assert known_asset_in("USDC.e_balance") is None + assert known_asset_in("USDC.eб") is None + # The longer name still matches on its own, and a sentence-final period is + # not a continuation. + assert known_asset_in("USDC.e drift on Arbitrum") == "USDC.e" + assert known_asset_in("balance in USDC.") == "USDC" + + +def test_the_template_fingerprint_keeps_services_apart(tmp_path: Path) -> None: + """Grouping must stay injective: two tenants whose service labels differ are + two templates, even where a coarse redaction would render both the same. + Keeping the raw value here matters because redacting the grouping key + instead would merge two tenants' distinct errors into one template.""" + store = Store(tmp_path) + _runner_session(store) + _write_config(tmp_path, service="api-alice@example.com") + + def fetch(_cfg: dict, _cursor: str | None) -> tuple[list[dict], str | None]: + return ([{"ts": "2026-08-23T16:00:00Z", "line": "TimeoutError boom"}], None) + + created, _ = scan_errors(store, fetch) + alice = store.row("activity", created[0]) + assert alice is not None + + bob = template_fingerprint( + service="api-bob@example.com", + error_class="TimeoutError", + template_sig="sig", + environment="prod", + ) + alice_fp = template_fingerprint( + service="api-alice@example.com", + error_class="TimeoutError", + template_sig="sig", + environment="prod", + ) + assert alice_fp != bob + assert alice["payload"]["template_fingerprint"].startswith("api-alice@example.com|")