Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
121 changes: 72 additions & 49 deletions cecli/helpers/key_detection.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -39,11 +37,13 @@
"apikeys",
"credential",
"credentials",
"jwt",
"key",
"keys",
"passwd",
"password",
"passwords",
"private",
"secret",
"secrets",
"token",
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = {}
Expand Down
2 changes: 1 addition & 1 deletion cecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion cecli/website/assets/sample.cecli.conf.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cecli/website/assets/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cecli/website/docs/config/conf.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cecli/website/docs/config/dotenv.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cecli/website/docs/config/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion cecli/website/docs/usage/modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mode>` command:

Expand Down
46 changes: 41 additions & 5 deletions tests/helpers/test_key_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == []
Expand All @@ -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():
Expand Down
25 changes: 22 additions & 3 deletions tests/helpers/test_loop_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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
Expand Down
Loading