Skip to content
Merged
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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to Code Context Control (C3) are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.115.0] - 2026-09-04

### Added — an advisory hint when the shell is used as a reader or a searcher (shell remediation S4)

48% of c3_shell commands were cat, head, tail, sed, grep, rg, find and ls
over project files — reads and searches that the code-intelligence tools
answer within budget, indexed, and with the index's exclusions. Cod's
ruling: advisory only; refusing on size would push agents to the native
shell and lose every control.

- `cli/tools/shell_nudge.py`: `bypass_hint(cmd, project_path)` appends at
most ONE `[c3_shell:hint]` line naming the equivalent call —
`c3_read(file_path=…, lines=[a,b])` for cat/head/`sed -n 'a,bp'`,
`c3_compress(mode='map')` then `c3_read` for tail, `c3_search(action=
'code'|'exact', query=…, path=…, ignore_case=…)` for grep/rg (identifier
→ code, anything else → exact), `c3_search(action='files', query=…)` for
`find -name` and recursive ls. Paths outside the project, `~`/`$VAR`
paths, `node_modules`, `dist`, `build`, `.git` and `.c3` are never hinted;
a plain `ls` is not a search. The command is never rewritten or refused.
- Telemetry `detail.hint` records the hint kind (`a read of …`, `a search`,
`a filename search`, `a listing`) so the follow rate and the false-positive
rate can be measured from `.c3/tool_telemetry.jsonl` before anything is
promoted. This closes the five-phase shell remediation (S0 2.111.0 …
S4 2.115.0).

## [2.114.0] - 2026-09-04

### Added — c3_shell_job: long work runs in a detached supervisor instead of leaving C3 (shell remediation S3)
Expand Down
2 changes: 1 addition & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
__version__ = "2.114.0"
__version__ = "2.115.0"


def _compress_file_cli(compressor, path, mode="smart", **kw):
Expand Down
13 changes: 13 additions & 0 deletions cli/tools/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from cli._shell_writes import shell_write_targets
from cli.tools import _grants
from cli.tools._helpers import finalize_with_tokens
from cli.tools.shell_nudge import bypass_hint
from cli.tools.shell_parsers import (
SUMMARY_MIN_LINES,
detect_runner,
Expand Down Expand Up @@ -697,6 +698,18 @@ def render_shell_response(cmd: str, result: dict, svc, *,
dependency_hint = _dependency_hint(cmd, result)
if dependency_hint:
body += f"--- hint ---\n{dependency_hint}\n"
# S4: ONE advisory line when the shell was used as a reader or a
# searcher over project files (48% of commands, measured 2026-09-04).
# Never a refusal, never a rewrite; telemetry records that it was shown
# so the follow rate can be measured before anything is promoted.
stats["hint"] = None
try:
nudge = bypass_hint(cmd, str(getattr(svc, "project_path", "") or ""))
except Exception:
nudge = None
if nudge:
body += nudge + "\n"
stats["hint"] = nudge.split(": ", 1)[0].replace("[c3_shell:hint] ", "")
if touched_files:
body += f"--- ledger ---\nlogged {len(touched_files)} file(s)\n"
if cred_names:
Expand Down
193 changes: 193 additions & 0 deletions cli/tools/shell_nudge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""Advisory bypass hints for c3_shell (shell remediation S4).

Measured 2026-09-04 over 7,278 shell_exec events: 48% of c3_shell commands
were cat/head/tail/sed/grep/rg/find/ls over project files — reads and
searches that the code-intelligence tools answer within budget, indexed,
and with the index's exclusions (node_modules, minified bundles, .c3 logs),
none of which a raw shell read gets. Cod's ruling for this phase: advisory
only. Refusing on size would push agents to the native shell and lose
every control; the S1 budget already removes the catastrophic cost. So this
module produces at most ONE line per response naming the c3_* call that
answers the same question, and telemetry records that a hint was shown so
the follow rate and the false-positive rate can be measured before anything
is promoted.

Pure: ``bypass_hint(cmd, project_path) -> str | None``. Never rewrites the
command, never refuses, never inspects the filesystem beyond a cheap
"is this path inside the project" check.
"""
from __future__ import annotations

import os
import re
import shlex
from pathlib import Path

_PREFIX = re.compile(
r"""^(?:\s*(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*|\w+=(?:"[^"]*"|'[^']*'|\S*)\s*;?\s*))*""")
_OPERATORS = {"|", "||", "&&", ";", ">", ">>", "<", "2>", "2>&1", "&"}
_READERS = {"cat", "head", "tail", "less", "more", "type"}
_SEARCHERS = {"grep", "egrep", "fgrep", "rg"}
_FINDERS = {"find", "ls", "tree", "dir"}
_SED_RANGE = re.compile(r"^(\d+),(\d+)p$")
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
_SKIP_DIRS = ("node_modules", ".git", "dist", "build", ".c3", "__pycache__")
_MAX_LINES_HINT = 200


def _first_segment(cmd: str) -> list[str]:
"""Tokens of the first simple command after any cd/VAR= prefix."""
rest = _PREFIX.sub("", (cmd or "").strip())
if not rest:
return []
try:
toks = shlex.split(rest, posix=True)
except ValueError:
toks = rest.split()
out: list[str] = []
for tok in toks:
if tok in _OPERATORS:
break
out.append(tok)
return out


def _head(tok: str) -> str:
head = tok.rsplit("/", 1)[-1].rsplit("\\", 1)[-1].lower()
return head[:-4] if head.endswith(".exe") else head


def _inside_project(path: str, project_path: str) -> bool:
"""True when ``path`` is relative or resolves under the project root."""
if not path or path.startswith("-"):
return False
if any(seg in _SKIP_DIRS for seg in re.split(r"[\\/]", path)):
return False
if path.startswith(("~", "$")) or "${" in path:
return False # home or a variable: not a project path we can name
p = Path(path)
# Path("/var/log") is not absolute on Windows and Path("C:/x") is not
# absolute on POSIX: treat both spellings as absolute everywhere.
foreign_absolute = path.startswith(("/", "\\")) or bool(re.match(r"^[A-Za-z]:[/\\]", path))
if not p.is_absolute() and not foreign_absolute:
return True
if foreign_absolute and not p.is_absolute():
return False
try:
root = Path(project_path).resolve()
return str(p.resolve()).lower().startswith(str(root).lower() + os.sep) or p.resolve() == root
except Exception:
return False


def _rel(path: str) -> str:
p = path.replace("\\", "/")
if p.startswith("./"):
p = p[2:]
return p.rstrip("/") or "."


def _positional(toks: list[str], flags_with_arg: set[str]) -> list[str]:
out: list[str] = []
skip = False
for tok in toks[1:]:
if skip:
skip = False
continue
if tok in flags_with_arg:
skip = True
continue
if tok.startswith("-") and len(tok) > 1:
continue
out.append(tok)
return out


def bypass_hint(cmd: str, project_path: str) -> str | None:
"""One advisory line, or None when the command is not a plain read/search."""
toks = _first_segment(cmd)
if not toks:
return None
head = _head(toks[0])

if head in _READERS:
files = [t for t in _positional(toks, {"-n", "-c"}) if _inside_project(t, project_path)]
if len(files) != 1:
return None
f = _rel(files[0])
if head == "tail":
return (f"[c3_shell:hint] a read of {f}: c3_compress(file_path='{f}', mode='map') "
f"then c3_read(file_path='{f}', lines=[a,b]) returns the region within budget and indexed")
lines = ""
if head == "head":
m = re.search(r"(?:^|\s)-n\s*(\d+)|(?:^|\s)-(\d+)\b", " ".join(toks[1:]))
n = int(next(g for g in m.groups() if g)) if m else 10
lines = f", lines=[1,{n}]"
return (f"[c3_shell:hint] a read of {f}: c3_read(file_path='{f}'{lines}) returns it within "
f"budget and indexed; c3_compress(mode='map') first for a large file")

if head == "sed":
# sed -n 'a,bp' file
args = toks[1:]
rng = None
files: list[str] = []
for tok in args:
m = _SED_RANGE.match(tok.strip("'\""))
if m:
rng = (int(m.group(1)), int(m.group(2)))
elif tok == "-n" or tok.startswith("-"):
continue
else:
files.append(tok)
files = [t for t in files if _inside_project(t, project_path)]
if rng is None or len(files) != 1:
return None
f = _rel(files[0])
return (f"[c3_shell:hint] a read of {f} lines {rng[0]}-{rng[1]}: "
f"c3_read(file_path='{f}', lines=[{rng[0]},{rng[1]}]) returns it within budget and indexed")

if head in _SEARCHERS:
pos = _positional(toks, {"-e", "--regexp", "-f", "--file", "-A", "-B", "-C", "-m", "-d",
"--include", "--exclude", "--exclude-dir", "-t", "--type", "-g", "--glob"})
# -e pattern form
pattern = None
for i, tok in enumerate(toks[1:], 1):
if tok in ("-e", "--regexp") and i + 1 < len(toks):
pattern = toks[i + 1]
break
if pattern is None:
if not pos:
return None
pattern, pos = pos[0], pos[1:]
targets = [t for t in pos if _inside_project(t, project_path)]
if pos and not targets:
return None # searching outside the project: not ours to hint
flags = " ".join(toks[1:])
ignore = ", ignore_case=True" if re.search(r"(?:^|\s)-\w*i\w*(?:\s|$)", flags) else ""
path_arg = f", path='{_rel(targets[0])}'" if targets and _rel(targets[0]) not in (".", "") else ""
action = "code" if _IDENTIFIER.match(pattern) else "exact"
safe = pattern.replace("'", "\\'")
return (f"[c3_shell:hint] a search: c3_search(action='{action}', query='{safe}'{path_arg}{ignore}) "
f"runs over the index (no node_modules, minified bundles or .c3 logs) within budget")

if head in _FINDERS:
pos = _positional(toks, {"-name", "-iname", "-path", "-type", "-maxdepth", "-mindepth", "-newer", "-size"})
name = None
for i, tok in enumerate(toks[1:], 1):
if tok in ("-name", "-iname") and i + 1 < len(toks):
name = toks[i + 1]
break
if head == "find" and name is None:
return None
targets = [t for t in pos if _inside_project(t, project_path)]
if head in ("ls", "tree", "dir"):
if "-R" not in toks and "-r" not in toks and head != "tree":
return None # a plain ls is cheap and not a search
q = _rel(targets[0]) if targets else "*"
return (f"[c3_shell:hint] a listing: c3_search(action='files', query='{q}') or "
f"c3_compress(file_path='<dir>', mode='map') walks the index instead")
path_arg = f", path='{_rel(targets[0])}'" if targets and _rel(targets[0]) != "." else ""
return (f"[c3_shell:hint] a filename search: c3_search(action='files', query='{name}'{path_arg}) "
f"walks the index (no node_modules or build output) within budget")

return None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "code-context-control"
version = "2.114.0"
version = "2.115.0"
description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
18 changes: 9 additions & 9 deletions tests/shell_eval/baseline_fixture.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"suite": "fixture",
"generated_at": "2026-09-04T11:29:38",
"c3_version": "2.113.0",
"generated_at": "2026-09-04T11:33:17",
"c3_version": "2.114.0",
"renderer": "cli.tools.shell.render_shell_response",
"default_max_bytes": 18432,
"aggregates": {
Expand All @@ -17,10 +17,10 @@
"must_keep_retention_must_pass": 1.0,
"bytes_p50": 3127.0,
"bytes_p95": 8308.0,
"tokens_p50": 1059.0,
"tokens_p50": 1077.5,
"tokens_p95": 2399.5,
"render_ms_p50": 0.9,
"render_ms_p95": 269.4,
"render_ms_p50": 1.1,
"render_ms_p95": 263.2,
"must_pass_failed": [],
"xfail_passing": [],
"errors": []
Expand Down Expand Up @@ -77,7 +77,7 @@
"status": "pass",
"gate": "must_pass",
"phase": null,
"response_bytes": 3718,
"response_bytes": 3896,
"retention": 1.0
},
"python_traceback": {
Expand Down Expand Up @@ -105,14 +105,14 @@
"status": "pass",
"gate": "must_pass",
"phase": null,
"response_bytes": 3832,
"response_bytes": 3985,
"retention": null
},
"binary_single_line": {
"status": "pass",
"gate": "must_pass",
"phase": null,
"response_bytes": 1260,
"response_bytes": 1417,
"retention": null
},
"ansi_colored": {
Expand Down Expand Up @@ -154,7 +154,7 @@
"status": "pass",
"gate": "must_pass",
"phase": null,
"response_bytes": 3716,
"response_bytes": 3846,
"retention": 1.0
},
"git_status_long": {
Expand Down
Loading
Loading