diff --git a/CHANGELOG.md b/CHANGELOG.md index c4bd759..3f7b821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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.157.1] - 2026-10-14 + +### Fixed — the phone's session search and overview no longer leak hidden transcript text + +With `mobile_sessions_transcripts` off, the gateway blanked prompts in the +rows it returned, but `GET /api/mobile/sessions?q=` still matched against the +unblanked prompts and first-prompt titles, so a search told the phone which +sessions contained a word. `/api/mobile/sessions/overview` also passed a +first-prompt title through as `newest.title`, because +`session_catalog.overview()` did not say where the title came from. + +- `session_catalog._matches` / `filter_rows` / `list_sessions` / + `list_many` take `transcripts=` (default true, so the Hub is unchanged); + false searches only the id, branch, note, stale reason and a title that + did not come from the first prompt. +- `session_catalog.overview()` adds `title_source` to `newest`. +- Found by the c3-mobile Sessions screen's builder reviewing the v7 routes. + ## [2.157.0] - 2026-10-14 ### Added — the fleet views on the mobile gateway (API version 7) diff --git a/cli/c3.py b/cli/c3.py index 69f0a60..83163b0 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -91,7 +91,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.157.0" +__version__ = "2.157.1" # The PreToolUse matcher for native subagent calls (installer and hub migration). AGENT_MATCHER = "Agent|Task" diff --git a/cli/guide/tools.html b/cli/guide/tools.html index 296e6d8..d5e7756 100644 --- a/cli/guide/tools.html +++ b/cli/guide/tools.html @@ -1610,7 +1610,7 @@

Examples

c3_schedule
execute - v2.157.0 + v2.157.1
Scheduled tasks — a skill, prompt or command the Oracle fires on a cadence, autonomously or after a human approves each run diff --git a/oracle/services/mobile_fleet.py b/oracle/services/mobile_fleet.py index f925a36..2dfd9e1 100644 --- a/oracle/services/mobile_fleet.py +++ b/oracle/services/mobile_fleet.py @@ -455,6 +455,9 @@ def mobile_sessions_list(): Query: ``stale`` hide|only|likely|all (default hide), ``q``, ``limit`` (default 50, max 200), ``before`` (ISO cursor from ``next_before``). + Without ``mobile_sessions_transcripts``, ``q`` never matches prompt text + or a first-prompt title: a hit on hidden text would reveal it a word at + a time. """ gate = m._feature_or_404("sessions") if gate: @@ -473,11 +476,12 @@ def mobile_sessions_list(): if err: return err res = sc.list_sessions(proj["path"], name=proj["name"], stale=stale, - q=q, limit=limit, before=before) + q=q, limit=limit, before=before, + transcripts=transcripts) res["errors"] = [] else: res = sc.list_many(_registered(), stale=stale, q=q, limit=limit, - before=before) + before=before, transcripts=transcripts) except Exception as exc: return m._svc_error(exc) res["sessions"] = [_session_wire(r, transcripts) diff --git a/pyproject.toml b/pyproject.toml index 59a1abe..9834009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.157.0" +version = "2.157.1" 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/services/session_catalog.py b/services/session_catalog.py index a42b3c8..ff36082 100644 --- a/services/session_catalog.py +++ b/services/session_catalog.py @@ -808,17 +808,28 @@ def _resume_view(rid: str, provider: str, meta: dict, live: bool, project) -> di # ── Public API ───────────────────────────────────────────────────────────── -def _matches(row: dict, q: str) -> bool: +def _matches(row: dict, q: str, *, transcripts: bool = True) -> bool: + """Every word of ``q`` appears in the row. + + ``transcripts=False`` searches only what a caller without transcript + access is shown: no prompts, and no title copied from the first prompt. + Matching on text the caller cannot see would still tell it which + sessions contain a word. + """ if not q: return True - hay = " ".join([row["id"], row["title"], row["first_prompt"], row["last_prompt"], - row["branch"], ((row.get("note") or {}).get("summary") or ""), - ((row.get("stale") or {}).get("reason") or "")]).lower() + parts = [row["id"], row["branch"], ((row.get("note") or {}).get("summary") or ""), + ((row.get("stale") or {}).get("reason") or "")] + if transcripts: + parts += [row["title"], row["first_prompt"], row["last_prompt"]] + elif row.get("title_source") != "first_prompt": + parts.append(row["title"]) + hay = " ".join(parts).lower() return all(word in hay for word in q.lower().split()) def filter_rows(rows: list[dict], *, stale: str = "hide", q: str = "", - limit: int = 50, before: str = "") -> dict: + limit: int = 50, before: str = "", transcripts: bool = True) -> dict: """Apply the list filters to already-built rows (newest first).""" stale = (stale or "hide").lower() out = [] @@ -831,7 +842,7 @@ def filter_rows(rows: list[dict], *, stale: str = "hide", q: str = "", continue if before and row["last_active"] >= before: continue - if not _matches(row, q): + if not _matches(row, q, transcripts=transcripts): continue out.append(row) limit = max(1, min(int(limit or 50), 500)) @@ -841,18 +852,20 @@ def filter_rows(rows: list[dict], *, stale: str = "hide", q: str = "", def list_sessions(project, *, name: str = "", stale: str = "hide", q: str = "", - limit: int = 50, before: str = "") -> dict: + limit: int = 50, before: str = "", transcripts: bool = True) -> dict: """``{"sessions": [row...], "next_before": iso|""}`` for one project. stale: ``hide`` (default), ``only``, ``likely`` (unmarked rows with hints) - or ``all``. + or ``all``. ``transcripts=False`` keeps ``q`` off the prompt text. """ rows, _ = _build(project, name) - return filter_rows(rows, stale=stale, q=q, limit=limit, before=before) + return filter_rows(rows, stale=stale, q=q, limit=limit, before=before, + transcripts=transcripts) def list_many(projects: list[dict], *, stale: str = "hide", q: str = "", - limit: int = 50, before: str = "", workers: int = 8) -> dict: + limit: int = 50, before: str = "", workers: int = 8, + transcripts: bool = True) -> dict: """Rows from several projects (``[{name, path}]``) merged newest first. Projects build in parallel; one unreadable project is reported under @@ -878,7 +891,8 @@ def one(p): if err: errors.append({"path": path, "error": err}) merged.sort(key=lambda r: r["last_active"], reverse=True) - result = filter_rows(merged, stale=stale, q=q, limit=limit, before=before) + result = filter_rows(merged, stale=stale, q=q, limit=limit, before=before, + transcripts=transcripts) result["errors"] = errors return result @@ -1042,7 +1056,10 @@ def overview(project, *, name: str = "") -> dict: if dirty: _save_cache(project, cache) if meta: - newest = {"id": meta["id"], "title": meta.get("title") or "(untitled)"} + # title_source travels with the title so a surface that withholds + # transcript text can tell a first-prompt title from a summary. + newest = {"id": meta["id"], "title": meta.get("title") or "(untitled)", + "title_source": meta.get("title_source") or "none"} return {"name": name or project.name, "path": str(project), "counts": {"total": len(files), "live": len(live), "stale": len(stale_ids & {f.stem for f in files}), "idle": idle}, diff --git a/tests/test_mobile_fleet.py b/tests/test_mobile_fleet.py index 403029e..682b86d 100644 --- a/tests/test_mobile_fleet.py +++ b/tests/test_mobile_fleet.py @@ -426,6 +426,50 @@ def test_detail_drops_the_preview_by_default(self): self.assertEqual(body["preview"], []) self.assertNotIn("secret turn", json.dumps(body)) + def test_search_never_matches_hidden_text(self): + rows = [_row(self.proj), + _row(self.proj, id="9" * 36, title="deploy the parser", + title_source="first_prompt", first_prompt="deploy the parser")] + with mock.patch.object(sc, "_build", return_value=(rows, {})): + # "first" is only in a prompt; "deploy" only in a first-prompt title. + for q in ("first", "last", "deploy"): + _, body = self.get("/api/mobile/sessions", + project=str(self.proj), q=q) + self.assertEqual(body["sessions"], [], q) + # Visible text still matches. + _, body = self.get("/api/mobile/sessions", project=str(self.proj), + q="parser main") + self.assertEqual(len(body["sessions"]), 1) + srv._cfg["mobile_sessions_transcripts"] = True + _, body = self.get("/api/mobile/sessions", project=str(self.proj), + q="first") + self.assertEqual(len(body["sessions"]), 1) + + def test_search_across_projects_respects_the_switch_too(self): + rows = [_row(self.proj)] + with mock.patch.object(sc, "_build", return_value=(rows, {})): + _, body = self.get("/api/mobile/sessions", q="first") + self.assertEqual(body["sessions"], []) + + def test_overview_withholds_a_real_first_prompt_title(self): + # Unmocked session_catalog.overview: newest must carry title_source + # for the withholding to fire at all. + tdir = Path(self._tmp.name) / "transcripts" + tdir.mkdir() + transcript = tdir / "abc.jsonl" + transcript.write_text("{}\n", encoding="utf-8") + meta = {"id": "abc", "title": "my secret first prompt", + "title_source": "first_prompt"} + with mock.patch.object(sc, "claude_transcript_dir", return_value=tdir), \ + mock.patch.object(sc, "_transcript_files", + return_value=[transcript]), \ + mock.patch.object(sc, "_cached", return_value=meta): + status, body = self.get("/api/mobile/sessions/overview") + self.assertEqual(status, 200, body) + self.assertNotIn("my secret first prompt", json.dumps(body)) + newest = body["projects"][0]["newest"] + self.assertEqual(newest["title"], "(untitled)") + def test_unregistered_project_is_a_404(self): status, _ = self.get("/api/mobile/sessions", project=str(self.ghost)) self.assertEqual(status, 404)