From 9a7c522a2313837b489e87dbb825def61e8c5370 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Fri, 4 Sep 2026 11:00:25 -0400 Subject: [PATCH 1/3] feat(shell): an advisory hint names the c3_* call when the shell is used as a reader or searcher (S4 helper) Claude-Session: https://claude.ai/code/session_018VQLfSMFw9Lfd9mGntt19H --- cli/tools/shell_nudge.py | 191 +++++++++++++++++++++++++++++++++++ tests/test_shell_nudge_s4.py | 98 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 cli/tools/shell_nudge.py create mode 100644 tests/test_shell_nudge_s4.py diff --git a/cli/tools/shell_nudge.py b/cli/tools/shell_nudge.py new file mode 100644 index 0000000..665f3e3 --- /dev/null +++ b/cli/tools/shell_nudge.py @@ -0,0 +1,191 @@ +"""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) + posix_absolute = path.startswith(("/", "\\")) # Path("/var/log") is not absolute on Windows + if not p.is_absolute() and not posix_absolute: + return True + if posix_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='', 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 diff --git a/tests/test_shell_nudge_s4.py b/tests/test_shell_nudge_s4.py new file mode 100644 index 0000000..0e53f1b --- /dev/null +++ b/tests/test_shell_nudge_s4.py @@ -0,0 +1,98 @@ +"""S4 of the c3_shell remediation: an advisory hint when the shell is used as +a reader or a searcher over project files (48% of commands, measured +2026-09-04). One line, never a refusal, never a rewrite. +""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from cli.tools.shell_nudge import bypass_hint # noqa: E402 + +PROJ = str(Path(__file__).resolve().parent.parent) + + +def hint(cmd: str) -> str | None: + return bypass_hint(cmd, PROJ) + + +class TestReads(unittest.TestCase): + def test_cat_of_a_project_file(self): + h = hint("cat CHANGELOG.md") + self.assertIsNotNone(h) + self.assertTrue(h.startswith("[c3_shell:hint]")) + self.assertIn("c3_read(file_path='CHANGELOG.md')", h) + + def test_cd_prefix_and_head_n(self): + h = hint('cd "U:/x y" && head -n 40 services/indexer.py') + self.assertIn("c3_read(file_path='services/indexer.py', lines=[1,40])", h) + h = hint("head -60 cli/c3.py") + self.assertIn("lines=[1,60]", h) + + def test_tail_points_at_map_then_read(self): + h = hint("tail -n 30 .c3/hub.log") + self.assertIsNone(h) # .c3 is skipped: not a source read + h = hint("tail -n 30 docs/shell-eval.md") + self.assertIn("c3_compress(file_path='docs/shell-eval.md', mode='map')", h) + + def test_sed_range(self): + h = hint("sed -n '120,180p' cli/tools/shell.py") + self.assertIn("c3_read(file_path='cli/tools/shell.py', lines=[120,180])", h) + self.assertIsNone(hint("sed -i 's/a/b/' cli/tools/shell.py")) # an edit, not a read + + def test_two_files_or_outside_project_is_not_hinted(self): + self.assertIsNone(hint("cat a.py b.py")) + self.assertIsNone(hint("cat /etc/hosts")) + self.assertIsNone(hint("cat ~/.c3/config.json")) + self.assertIsNone(hint("cat $HOME/x")) + self.assertIsNone(hint("cat node_modules/left-pad/index.js")) + + +class TestSearches(unittest.TestCase): + def test_grep_identifier_maps_to_code_search(self): + h = hint("grep -rn CodeIndex services/") + self.assertIn("c3_search(action='code', query='CodeIndex', path='services')", h) + + def test_grep_regex_maps_to_exact_with_ignore_case(self): + h = hint('grep -rni "content-security-policy\\|unsafe" --include=*.js .') + self.assertIn("action='exact'", h) + self.assertIn("ignore_case=True", h) + self.assertNotIn("path=", h) + + def test_rg_e_pattern_and_pipe(self): + h = hint("rg -e 'def handle_' cli | head -20") + self.assertIn("query='def handle_'", h) + self.assertIn("path='cli'", h) + + def test_grep_outside_project_is_not_hinted(self): + self.assertIsNone(hint("grep -rn foo /var/log/syslog")) + self.assertIsNone(hint("grep -rn foo C:/Windows/Temp")) + + def test_grep_without_pattern(self): + self.assertIsNone(hint("grep")) + + +class TestFinds(unittest.TestCase): + def test_find_name(self): + h = hint("find . -name '*.yml' -not -path '*/node_modules/*'") + self.assertIn("c3_search(action='files', query='*.yml')", h) + + def test_find_without_name_and_plain_ls(self): + self.assertIsNone(hint("find . -type d -mtime -1")) + self.assertIsNone(hint("ls -la")) + self.assertIsNotNone(hint("ls -R services")) + + +class TestNotReads(unittest.TestCase): + def test_everything_else_is_silent(self): + for cmd in ("git status", "python -m pytest tests -q", "npm run build", "echo hi", + "curl -s http://127.0.0.1:3330/api/health", "", "python - <<'EOF'\nprint(1)\nEOF", + "PYTHONPATH= c3 --version", "gh pr list"): + self.assertIsNone(hint(cmd), cmd) + + +if __name__ == "__main__": + unittest.main() From 5cf70e963de2e597ffd7ea423534c98e55fda113 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Fri, 4 Sep 2026 11:33:50 -0400 Subject: [PATCH 2/3] feat(shell): one advisory line names the c3_* call when the shell was used as a reader or searcher (S4) Claude-Session: https://claude.ai/code/session_018VQLfSMFw9Lfd9mGntt19H --- CHANGELOG.md | 25 +++++++++++++++++++++++ cli/c3.py | 2 +- cli/tools/shell.py | 13 ++++++++++++ pyproject.toml | 2 +- tests/shell_eval/baseline_fixture.json | 18 ++++++++--------- tests/test_shell_nudge_s4.py | 28 ++++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a3029..2985ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/cli/c3.py b/cli/c3.py index cbd5b62..ca96579 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -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): diff --git a/cli/tools/shell.py b/cli/tools/shell.py index 48d1ec7..b07809b 100644 --- a/cli/tools/shell.py +++ b/cli/tools/shell.py @@ -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, @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 21cec6c..c881759 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/shell_eval/baseline_fixture.json b/tests/shell_eval/baseline_fixture.json index eb2ab74..1dd1aa7 100644 --- a/tests/shell_eval/baseline_fixture.json +++ b/tests/shell_eval/baseline_fixture.json @@ -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": { @@ -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": [] @@ -77,7 +77,7 @@ "status": "pass", "gate": "must_pass", "phase": null, - "response_bytes": 3718, + "response_bytes": 3896, "retention": 1.0 }, "python_traceback": { @@ -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": { @@ -154,7 +154,7 @@ "status": "pass", "gate": "must_pass", "phase": null, - "response_bytes": 3716, + "response_bytes": 3846, "retention": 1.0 }, "git_status_long": { diff --git a/tests/test_shell_nudge_s4.py b/tests/test_shell_nudge_s4.py index 0e53f1b..1ece2e7 100644 --- a/tests/test_shell_nudge_s4.py +++ b/tests/test_shell_nudge_s4.py @@ -86,6 +86,34 @@ def test_find_without_name_and_plain_ls(self): self.assertIsNotNone(hint("ls -R services")) +class TestWiring(unittest.TestCase): + """render_shell_response appends the one line and telemetry records its kind.""" + + def _render(self, cmd): + from types import SimpleNamespace + + from cli.tools import shell as shell_mod + from services.output_filter import OutputFilter + svc = SimpleNamespace(project_path=PROJ, activity_log=None, edit_ledger=None, + output_filter=OutputFilter({"HYBRID_DISABLE_TIER1": True}), + session_mgr=None, hybrid_config={}) + result = {"exit_code": 0, "stdout": "x = 1\n", "stderr": "", "duration_ms": 3, + "timed_out": False, "shell": "git-bash"} + return shell_mod.render_shell_response(cmd, result, svc) + + def test_read_gets_one_line_and_a_telemetry_kind(self): + body, stats = self._render("cat cli/c3.py") + self.assertEqual(body.count("[c3_shell:hint]"), 1) + self.assertIn("c3_read(file_path='cli/c3.py')", body) + self.assertEqual(stats["hint"], "a read of cli/c3.py") + self.assertTrue(body.startswith("[c3_shell:OK] 3ms\n$ cat cli/c3.py\n--- stdout ---\nx = 1\n")) + + def test_other_commands_are_untouched(self): + body, stats = self._render("echo hi") + self.assertNotIn("[c3_shell:hint]", body) + self.assertIsNone(stats["hint"]) + + class TestNotReads(unittest.TestCase): def test_everything_else_is_silent(self): for cmd in ("git status", "python -m pytest tests -q", "npm run build", "echo hi", From 08986cf7c4b8ffb81a1d53c60018a4b6308a5af6 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Fri, 4 Sep 2026 11:49:24 -0400 Subject: [PATCH 3/3] fix(shell): a drive-letter path is outside the project on POSIX too (S4 CI) Claude-Session: https://claude.ai/code/session_018VQLfSMFw9Lfd9mGntt19H --- cli/tools/shell_nudge.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cli/tools/shell_nudge.py b/cli/tools/shell_nudge.py index 665f3e3..3a243d3 100644 --- a/cli/tools/shell_nudge.py +++ b/cli/tools/shell_nudge.py @@ -66,10 +66,12 @@ def _inside_project(path: str, project_path: str) -> bool: if path.startswith(("~", "$")) or "${" in path: return False # home or a variable: not a project path we can name p = Path(path) - posix_absolute = path.startswith(("/", "\\")) # Path("/var/log") is not absolute on Windows - if not p.is_absolute() and not posix_absolute: + # 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 posix_absolute and not p.is_absolute(): + if foreign_absolute and not p.is_absolute(): return False try: root = Path(project_path).resolve()