From 43d64630ab28a7066cd726215e266722d07b7d6e Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 5 Nov 2026 07:42:35 -0500 Subject: [PATCH 1/4] Only track loops in agent modes --- cecli/coders/base_coder.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index a6714110d1c..deede113a49 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -4165,6 +4165,7 @@ async def show_send_output(self, completion): async def show_send_output_stream(self, completion): received_content = False chunk_index = 0 + track_loops = self.edit_format in ("agent", "subagent") content_detector = LoopDetector(self.repetition_penalties) tool_detector = LoopDetector(self.repetition_penalties) loop_detected = False @@ -4213,7 +4214,8 @@ async def show_send_output_stream(self, completion): tool_call_chunk.function.arguments ) - tool_detector.push(tool_call_chunk.function.arguments) + if track_loops: + tool_detector.push(tool_call_chunk.function.arguments) except (AttributeError, IndexError): # Handle cases where the response structure doesn't match expectations @@ -4279,7 +4281,7 @@ async def show_send_output_stream(self, completion): chunk._hidden_params["created_at"] = chunk_index self.partial_response_chunks.append(chunk) - if text: + if text and track_loops: content_detector.push(text) if self.show_pretty(): From 5a5450fa396939018d0521bb82f40ca086166584 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 5 Nov 2026 07:52:13 -0500 Subject: [PATCH 2/4] Update docs for agent mode default --- cecli/main.py | 2 +- cecli/website/assets/sample.cecli.conf.yml | 2 +- cecli/website/assets/sample.env | 2 +- cecli/website/docs/config/conf.md | 2 +- cecli/website/docs/config/dotenv.md | 2 +- cecli/website/docs/config/options.md | 2 +- cecli/website/docs/usage/modes.md | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cecli/main.py b/cecli/main.py index bd210673b04..62de0a8c0c4 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1403,7 +1403,7 @@ def get_io(pretty): main_model=main_model, # Agent mode is the startup default; an explicit flag/config/env value # still wins, and reloads keep the session's current mode. - edit_format=args.edit_format or "agent", + edit_format=args.edit_format or (from_coder.edit_format if from_coder else "agent"), io=io, args=args, repo=repo, diff --git a/cecli/website/assets/sample.cecli.conf.yml b/cecli/website/assets/sample.cecli.conf.yml index a0275eee566..ebaaad8cd19 100644 --- a/cecli/website/assets/sample.cecli.conf.yml +++ b/cecli/website/assets/sample.cecli.conf.yml @@ -92,7 +92,7 @@ ## Timeout in seconds for API calls (default: None) #timeout: xxx -## Specify what edit format the LLM should use (default depends on model) +## Specify what edit format the LLM should use (default: agent) #edit-format: xxx ## Use architect edit format for the main chat diff --git a/cecli/website/assets/sample.env b/cecli/website/assets/sample.env index 1749fe0ac05..fb8d909f8ec 100644 --- a/cecli/website/assets/sample.env +++ b/cecli/website/assets/sample.env @@ -81,7 +81,7 @@ ## Timeout in seconds for API calls (default: None) #AIDER_TIMEOUT= -## Specify what edit format the LLM should use (default depends on model) +## Specify what edit format the LLM should use (default: agent) #AIDER_EDIT_FORMAT= ## Use architect edit format for the main chat diff --git a/cecli/website/docs/config/conf.md b/cecli/website/docs/config/conf.md index 629473bfcc5..6e51131cbcf 100644 --- a/cecli/website/docs/config/conf.md +++ b/cecli/website/docs/config/conf.md @@ -280,7 +280,7 @@ Below is a sample of the YAML config file, which you can also [download from Git ## Timeout in seconds for API calls (default: None) #timeout: xxx -## Specify what edit format the LLM should use (default depends on model) +## Specify what edit format the LLM should use (default: agent) #edit-format: xxx ## Use architect edit format for the main chat diff --git a/cecli/website/docs/config/dotenv.md b/cecli/website/docs/config/dotenv.md index 05eba0961ea..45e076cd282 100644 --- a/cecli/website/docs/config/dotenv.md +++ b/cecli/website/docs/config/dotenv.md @@ -108,7 +108,7 @@ Below is a sample `.env` file, which you can also [download from GitHub](https:/ ## Timeout in seconds for API calls (default: None) #CECLI_TIMEOUT= -## Specify what edit format the LLM should use (default depends on model) +## Specify what edit format the LLM should use (default: agent) #CECLI_EDIT_FORMAT= ## Use architect edit format for the main chat diff --git a/cecli/website/docs/config/options.md b/cecli/website/docs/config/options.md index 19268329662..79ba5942aa2 100644 --- a/cecli/website/docs/config/options.md +++ b/cecli/website/docs/config/options.md @@ -158,7 +158,7 @@ Verify the SSL cert when connecting to models (default: True) Default: True Envi Timeout in seconds for API calls (default: None) Environment variable: `CECLI_TIMEOUT` ### `--edit-format EDIT_FORMAT` -Specify what edit format the LLM should use (default depends on model) Environment variable: `CECLI_EDIT_FORMAT` Aliases: +Specify what edit format the LLM should use (default: agent) Environment variable: `CECLI_EDIT_FORMAT` Aliases: - `--edit-format EDIT_FORMAT` - `--chat-mode EDIT_FORMAT` diff --git a/cecli/website/docs/usage/modes.md b/cecli/website/docs/usage/modes.md index 51ca0b3fa60..e63991b66ba 100644 --- a/cecli/website/docs/usage/modes.md +++ b/cecli/website/docs/usage/modes.md @@ -14,7 +14,7 @@ Cecli has a few different chat modes: - `help` - cecli will answer questions about cecli: usage, configuration, troubleshooting, etc. - `agent` - cecli will autonomously explore and modify your codebase using local tools, discovering and managing the relevant files itself. -By default, cecli starts in "code" mode. As you are talking, you can send individual messages in a specific mode using `/code`, `/architect`, `/ask`, `/help`, and `/agent` commands: Using these `/`-commands applies just to that particular message. Your next message will go back to the active mode (usually "code" mode by default). +By default, cecli starts in "agent" mode. As you are talking, you can send individual messages in a specific mode using `/code`, `/architect`, `/ask`, `/help`, and `/agent` commands: Using these `/`-commands applies just to that particular message. Your next message will go back to the active mode (usually "agent" mode by default). You can switch the active mode in a sticky way with the `/chat-mode ` command: From d2d915b196d8040bc66734d5e90297661beb780e Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 5 Nov 2026 08:08:35 -0500 Subject: [PATCH 3/4] Fix loop tests --- tests/helpers/test_loop_detect.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/helpers/test_loop_detect.py b/tests/helpers/test_loop_detect.py index 15cc7c5f99e..c5bf2cee41c 100644 --- a/tests/helpers/test_loop_detect.py +++ b/tests/helpers/test_loop_detect.py @@ -6,7 +6,8 @@ * what the detector can and cannot catch from LLM / small-language-model output, * the bounded-LRU behaviour of the sentence cache, -* the ``show_send_output_stream`` integration that stops streaming early, +* the ``show_send_output_stream`` integration that stops streaming early in agent + modes (single-shot modes skip loop tracking entirely), * the ``send``-level handling that marks the turn as ``[SYSTEM INTERJECTION: AVOID REPETITION]`` and drops any pending tool calls so they are never executed, * the adaptive per-level allowances that relax when a stream loops and bleed back @@ -227,7 +228,7 @@ def add_to_usage_report(self, *a, **k): return a[0] if a else "" -def _make_coder(): +def _make_coder(edit_format="agent"): coder = Coder.__new__(Coder) coder.stream = True coder.args = SimpleNamespace(debug=False, show_thinking=False) @@ -251,7 +252,7 @@ def _make_coder(): coder._output_loop_message = "" coder.repetition_penalties = LoopThresholdTracker() coder._has_empty_reflected = False - coder.edit_format = "code" + coder.edit_format = edit_format coder.max_compaction_retries = 3 coder.enable_context_compaction = False coder.model_kwargs = {} @@ -328,6 +329,24 @@ async def test_show_stream_detects_tool_args_loop_and_stops(): assert "Char loop" in coder._output_loop_message +@pytest.mark.parametrize("edit_format", ["code", "ask", "architect"]) +async def test_show_stream_skips_loop_detection_outside_agent_modes(edit_format): + coder = _make_coder(edit_format=edit_format) + chunks = [ + _content_chunk("a" * 50), + _content_chunk("a" * 51), + _tool_chunk([_tc(0, "call_1", "Local--ls", "a" * 101)]), + ] + coder.consolidate_chunks = lambda: (None, None, None) + + async for _ in coder.show_send_output_stream(_agen(chunks)): + pass + + assert coder._output_loop_detected is False + assert coder._output_loop_message == "" + assert coder.repetition_penalties.factors == {"char": 1.0, "word": 1.0, "sentence": 1.0} + + async def test_show_stream_does_not_flag_normal_stream(): coder = _make_coder() # Real consolidation is expensive here, so stub it; we only assert that a From 03ea6f5260da4bdb5670af804f709fbed5e50d21 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 5 Nov 2026 09:00:02 -0500 Subject: [PATCH 4/4] Deterministic key detection for redaction --- cecli/helpers/key_detection.py | 121 +++++++++++++++++----------- tests/helpers/test_key_detection.py | 46 +++++++++-- 2 files changed, 113 insertions(+), 54 deletions(-) diff --git a/cecli/helpers/key_detection.py b/cecli/helpers/key_detection.py index 8c3cb73fbc0..76e26ed7fc5 100644 --- a/cecli/helpers/key_detection.py +++ b/cecli/helpers/key_detection.py @@ -1,8 +1,6 @@ -import math import os import re import threading -from collections import Counter from typing import Any, Iterable, List, Mapping, Optional, Set # A run of key-safe characters (letters, digits and -_. only, so never any @@ -39,11 +37,13 @@ "apikeys", "credential", "credentials", + "jwt", "key", "keys", "passwd", "password", "passwords", + "private", "secret", "secrets", "token", @@ -61,27 +61,45 @@ separator + suffix for separator in _SUFFIX_SEPARATORS for suffix in SECRET_NAME_SUFFIXES ) -# A name that contains a credential word that is not a suffix ("KEY_DESCRIPTION_V2") -# is not trusted on its own: the value must also look key-like, measured as -# Shannon entropy in bits per character. Passwords are allowed a lower bar -# because they are often human-chosen rather than random. -DEFAULT_CREDENTIAL_ENTROPY = 3.2 - -CREDENTIAL_ENTROPY_THRESHOLDS = { - "apikey": 3.2, - "apikeys": 3.2, - "credential": 3.0, - "credentials": 3.0, - "key": 3.2, - "keys": 3.2, - "passwd": 2.5, - "password": 2.5, - "passwords": 2.5, - "secret": 3.0, - "secrets": 3.0, - "token": 3.2, - "tokens": 3.2, -} +# Prefixes used by well-known providers. A value starting with one of these is +# key-shaped without needing any other check. +KNOWN_KEY_PREFIXES = ( + "sk-ant-", + "sk-proj-", + "sk-", + "AIzaSy", + "AKIA", + "ASIA", + "ghp_", + "gho_", + "ghs_", + "ghr_", + "github_pat_", + "glpat-", + "xoxa-", + "xoxb-", + "xoxp-", + "xoxr-", + "xoxs-", + "gsk_", + "hf_", + "r8_", + "sk_live_", + "rk_live_", + "SG.", + "eyJ", +) + +# A short run of same-case letters followed by a separator (``ghp_``, ``sk-``, +# ``LLM_``) marks a key-shaped value even when the rest is unknown. +_KEY_SHAPE_PREFIX_RE = re.compile(r"^(?:[A-Z]{2,5}|[a-z]{2,5})[-_]") + +# Two or more letter/digit transitions, e.g. ``a2bc3`` or ``1de456f``. Prose +# and identifiers rarely alternate runs this way. +_ALTERNATING_RUN_RE = re.compile(r"[A-Za-z]+\d+[A-Za-z]+|\d+[A-Za-z]+\d+") + +# Values starting with these are paths, identifiers rather than secrets. +_PATH_START_PREFIXES = ("/", "\\", "./", ".\\", "../", "..\\", "~/", "~\\", "~") # Name words that mark a value as an identifier (paths, models, session ids, # hostnames). These are never scanned for key-like tokens: config/env entries @@ -163,8 +181,8 @@ def detect_api_keys( punctuation that a key-like token regex cannot span. - containing a credential word, as a word or inside a longer token (e.g. "KEY_DESCRIPTION_V2" or "sortKey"): the whole value is taken only when - it also looks key-like (length, letter/digit mix and entropy above the - threshold for that word). + it also looks key-like (length, mixed case, alternating letter/digit + runs or a known provider prefix). - identifier-named (see BENIGN_NAME_WORDS) or path-shaped: not registered in full, because these legitimately hold long non-secret strings; any key-like tokens embedded in the value are still registered. @@ -301,7 +319,7 @@ def _scan_entry(name: Any, value: Any, found: Set[str], expand: bool, forced: bo return if _contains_secret_word(name) or _is_ambiguous_secret_name(name): - if _looks_like_key_value(text, name): + if _looks_like_key_value(text): _register_secret_value(text, found) return @@ -380,38 +398,51 @@ def _contains_secret_word(name: Any) -> bool: return bool(_name_words(name) & SECRET_NAME_WORDS) -def _looks_like_key_value(value: str, name: Any) -> bool: +def _looks_like_key_value(value: str) -> bool: """True if a value under a containing credential name looks like a key. - Best-effort: requires ``MIN_KEY_LENGTH`` characters, letters and digits and - no whitespace, so short or all-alpha passphrases under ambiguous names are - intentionally not detected. + Path-starting and whitespace-containing values are rejected. Otherwise the + value must match at least one key tell: a known provider prefix, a short + same-case prefix followed by ``-``/``_``, mixed upper/lowercase without path + separators, or an alternating letter/digit run. """ stripped = value.strip() - if len(stripped) < MIN_KEY_LENGTH or _looks_like_path(stripped): + if len(stripped) < MIN_KEY_LENGTH or _starts_with_path(stripped): return False if any(char.isspace() for char in stripped): return False - has_alpha = any(char.isalpha() for char in stripped) - has_digit = any(char.isdigit() for char in stripped) + if stripped.startswith(KNOWN_KEY_PREFIXES): + return True + + if _KEY_SHAPE_PREFIX_RE.match(stripped): + return True - if not has_alpha or not has_digit: + if "/" in stripped or "\\" in stripped: return False - return _shannon_entropy(stripped) >= _entropy_threshold(name) + if _has_mixed_case(stripped): + return True + + return bool(_ALTERNATING_RUN_RE.search(stripped)) + + +def _has_mixed_case(value: str) -> bool: + """True if ``value`` contains both uppercase and lowercase letters.""" + has_upper = any(char.isupper() for char in value) + has_lower = any(char.islower() for char in value) + return has_upper and has_lower -def _entropy_threshold(name: Any) -> float: - """Return the entropy bar for the credential words in ``name``.""" - words = _name_words(name) & set(CREDENTIAL_ENTROPY_THRESHOLDS) - if not words: - return DEFAULT_CREDENTIAL_ENTROPY +def _starts_with_path(value: str) -> bool: + """True if ``value`` begins like an absolute, relative or home path.""" + if _WINDOWS_PATH_RE.match(value): + return True - return min(CREDENTIAL_ENTROPY_THRESHOLDS[word] for word in words) + return value.startswith(_PATH_START_PREFIXES) def _looks_like_path(value: str) -> bool: @@ -476,14 +507,6 @@ def _register_embedded_keys(value: str, found: Set[str]) -> None: found.update(_KEY_LIKE_RE.findall(chunk)) -def _shannon_entropy(value: str) -> float: - """Return the Shannon entropy of ``value`` in bits per character.""" - counts = Counter(value) - length = len(value) - - return -sum((count / length) * math.log2(count / length) for count in counts.values()) - - def _redact_mapping(mapping: Mapping) -> Mapping: """Apply redaction to every value, keeping the mapping when unchanged.""" items = {} diff --git a/tests/helpers/test_key_detection.py b/tests/helpers/test_key_detection.py index 6a733cbef19..483222d2d6e 100644 --- a/tests/helpers/test_key_detection.py +++ b/tests/helpers/test_key_detection.py @@ -103,7 +103,7 @@ def test_ambiguous_suffix_names_require_key_like_values(): assert key_detection.detect_api_keys(merged, environ={}) == [] -def test_entropy_gate_rejects_whitespace_and_prose(): +def test_key_gate_rejects_whitespace_and_prose(): environ = {"sortKey": "rotate this api key every90 days"} assert key_detection.detect_api_keys(environ=environ) == [] @@ -123,10 +123,46 @@ def test_path_detection_handles_urls_windows_and_relative_paths(): assert not looks_like_path(FAKE_KEY) -def test_entropy_thresholds_cover_secret_words(): - assert set(key_detection.SECRET_NAME_SUFFIXES) <= set( - key_detection.CREDENTIAL_ENTROPY_THRESHOLDS - ) +def test_detects_provider_prefixes_for_ambiguous_names(): + environ = { + "sortKey": "AKIAABCDEFGHIJKLMNOPQRSTUV", + "lookupKey": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0", + } + + assert key_detection.detect_api_keys(environ=environ) == sorted(environ.values()) + + +def test_detects_short_same_case_prefixes_for_ambiguous_names(): + environ = { + "sortKey": "LLM_0123456789abcdefghijklmnopqrstuvwxyz", + "lookupKey": "abc_0123456789abcdefghijklmnopqrstuvwxyz", + } + + assert key_detection.detect_api_keys(environ=environ) == sorted(environ.values()) + + +def test_detects_mixed_case_and_alternating_values_for_ambiguous_names(): + environ = { + "sortKey": "aBcDeFgHiJkLmNoPqRsTuVwXyZ", + "lookupKey": "a2bc3d4ef5g6h7i8j9k0l1m2n3", + } + + assert key_detection.detect_api_keys(environ=environ) == sorted(environ.values()) + + +def test_rejects_path_starting_values_for_ambiguous_names(): + environ = { + "sortKey": "./aBcDeFgHiJkLmNoPqRsTuVwXyZ", + "lookupKey": r"C:\aBcDeFgHiJkLmNoPqRsTuVwXyZ", + } + + assert key_detection.detect_api_keys(environ=environ) == [] + + +def test_detects_private_and_jwt_named_values(): + environ = {"SIGNING_PRIVATE": "p@ss:w0rd!", "SESSION_JWT": "pa:ssw0rd"} + + assert set(key_detection.detect_api_keys(environ=environ)) == set(environ.values()) def test_keeps_slash_containing_secrets():