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
76 changes: 76 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.39.0] - 2026-06-22

A correctness & security hardening release. A multi-agent audit of C3 surfaced a
hook-enforcement bypass, several edit-ledger / session-store data-loss races, Windows
line-ending and subprocess bugs across the c3 tools, installer config-merge data-loss
risks, and three Oracle security gaps. All are fixed here.

### Security

- **Oracle `POST /api/config` was unauthenticated.** Any local process could
`POST {"api_require_auth": false}` to strip authentication off the entire Discovery
API, or repoint `ollama_base_url` to exfiltrate prompts. The endpoint now requires the
Bearer token and rejects unknown config keys (allowlisted from `DEFAULTS`).
- **Oracle `GET /api/apikey` leaked the raw token.** It returned the plaintext Bearer
token with no auth; it now returns a masked form unless a valid Bearer token is
presented (`generate`/`rotate` still reveal the new token once).
- **Oracle Discovery `project_path` was unvalidated.** Callers could read any `.c3`
project on the machine by path; project paths are now validated against discovered
projects before any read.

### Fixed

- **Enforcement bypass: any read-only `c3_*` call unlocked native `Edit`/`Write`.** The
PreToolUse signal fast-path allowed any native tool whenever *any* fresh c3 signal
existed, ignoring per-tool prerequisites. Write-class tools (Edit/Write/MultiEdit) now
require a `c3_edit`/`c3_edits`/`c3_agent` signal; read tools are unchanged.
- **`MultiEdit` and `NotebookEdit` bypassed enforcement and the edit ledger entirely** —
no PreToolUse/PostToolUse matcher was registered for them. Both are now enforced and
logged.
- **`c3_edit` rewrote whole LF files as CRLF on Windows.** A one-line edit flipped every
line ending; edits now preserve the file's original newline style (single, batch, and
create modes).
- **`c3_edit` batch mode wrote the file and logged a ledger entry even when zero patches
applied**, and crashed on a non-dict batch element. It now writes/logs only when a patch
actually changed the file, and returns a clear error for malformed batches.
- **Edit ledger could lose writes.** `tag_edit` did a lock-free full-file rewrite that
clobbered concurrent appends, and `log_edit` didn't take the write lock. `tag_edit` now
appends a tag patch under lock, `log_edit` is locked, edit ids carry a random suffix to
prevent hook/server collisions within the same second, and orphaned patches are logged.
- **`sessions.json` could be wiped on a corrupt/partial read.** The conversation store now
writes atomically (temp + `os.replace`) and, on a parse failure, backs up the corrupt
file instead of silently resetting the catalog to empty; `add_turn` index updates are
locked.
- **`c3_delegate(backend="claude")` was 100% broken** (a tuple-unpacking bug failed every
call). Fixed; all CLI runners now also decode subprocess output as UTF-8 (cp1252 crash
fix) and kill the full process tree on timeout.
- **JS/TS `export class/function/const` symbols were missing** from compression maps and
the file-memory index (the walker descended past the declaration). Exported symbols are
now indexed.
- **`c3_compress` rendered every class with Python `class Name:` syntax** regardless of
language; it now uses the language-appropriate declaration.
- **`c3_read(symbols=...)` could return truncated bodies** when a `}` appeared inside a
string or comment; the brace scanner now skips string/char/template literals and
comments.
- **`file_memory` lazy search index had a first-search-vs-background-update race**
(introduced with lazy init); build/update/search are now lock-guarded.
- **Installer config-merge data-loss risks.** `merge_c3_block` could corrupt `CLAUDE.md`
on out-of-order/duplicate markers; the global-`CLAUDE.md` writer could delete user
`#` headings placed after the managed block; and `upsert_toml_section` orphaned child
subtables (e.g. `[mcp_servers.c3.env]`) on re-install. All fixed; the global managed
region now uses explicit BEGIN/END markers.
- **Sticky unlocks from `c3_compress`/`c3_agent` were lost** (written only to a file no
hook reads); they now reach the enforcer's `.json` unlock map.
- Smaller fixes: `c3_read` negative/reversed/comma line specs and `lines=0`; `c3_validate`
process-tree kill + UTF-8 on Windows; empty-fact rejection in `c3_memory add`;
`context_snapshot` atomic writes + corrupt-latest fallback; `web_security` no longer
skips the host allowlist on a missing Host for mutating requests; Oracle chat/config
endpoints return JSON errors for bad bodies; Oracle MCP auth toggles apply without a
restart; the activity digest flags truncated scans; TOML `#` inside quoted values is no
longer stripped.

### Changed

- PreToolUse enforcement now distinguishes read-class from write-class c3 signals — a
behavior change for anyone who relied on a read-only c3 call to unlock a native write.

## [2.38.1] - 2026-06-14

Startup reliability fixes for the MCP server and for the Oracle on Windows.
Expand Down
41 changes: 39 additions & 2 deletions cli/_hook_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,46 @@ def get_tool_output(data: dict) -> tuple:


def get_tool_input_path(data: dict) -> str:
"""Extract file path from tool_input, handling both Claude (file_path) and Gemini (path)."""
"""Extract file path from tool_input, handling Claude (file_path),
Gemini (path), and NotebookEdit (notebook_path)."""
tool_input = data.get("tool_input", {})
return tool_input.get("file_path", "") or tool_input.get("path", "")
return (
tool_input.get("file_path", "")
or tool_input.get("path", "")
or tool_input.get("notebook_path", "")
)


def record_json_unlocks(editable: list, project_path: Path | None = None) -> None:
"""Record file paths as read+edit unlocked in .c3/unlocked_files.json.

This is the map that hook_pretool_enforce.py actually reads (the plain
.txt unlock list is not consumed by any hook). Mirrors the behaviour of
cli/hook_c3read._record_json_unlocks so c3_compress/c3_agent sticky
unlocks reach the enforcer. Fails silently on I/O errors.
"""
base = project_path if project_path is not None else Path.cwd()
json_path = base / ".c3" / "unlocked_files.json"
try:
existing: dict = {}
if json_path.exists():
try:
existing = json.loads(json_path.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
except Exception:
existing = {}
for fp in editable:
if not fp:
continue
normalized = str(Path(fp).resolve())
cats = set(existing.get(normalized, []))
cats.update({"read", "edit"})
existing[normalized] = sorted(cats)
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(json.dumps(existing), encoding="utf-8")
except Exception:
pass


def emit_additional_context(text: str, is_gemini: bool) -> None:
Expand Down
95 changes: 60 additions & 35 deletions cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
__version__ = "2.38.1"
__version__ = "2.39.0"


def _command_deps() -> CommandDeps:
Expand Down Expand Up @@ -4084,7 +4084,11 @@ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
header = f"[{section}]"

# Strip existing section (header + its key=value lines)
# Strip existing section (header + its key=value lines). Also strip any
# dotted child subtables (e.g. "[mcp_servers.c3.env]" under
# "[mcp_servers.c3]") so they are not orphaned beneath the re-appended
# section, which would corrupt the file on re-run.
child_prefix = f"{header[:-1]}." # "[mcp_servers.c3]" -> "[mcp_servers.c3."
lines = content.splitlines()
new_lines: list[str] = []
skip = False
Expand All @@ -4094,7 +4098,8 @@ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
skip = True
continue
if skip and stripped.startswith("["):
skip = False
if not stripped.startswith(child_prefix):
skip = False
if not skip:
new_lines.append(line)

Expand Down Expand Up @@ -4577,41 +4582,48 @@ def _ensure_global_claude_md() -> None:

existing = global_md.read_text(encoding="utf-8")

if _GLOBAL_CLAUDE_MD_MARKER not in existing:
# User has their own CLAUDE.md — append C3 section
merged = existing.rstrip() + "\n\n" + _GLOBAL_CLAUDE_MD_CONTENT
global_md.write_text(merged, encoding="utf-8")
print(f"Updated {global_md} (appended C3 enforcement)")
return
# The C3-managed region is delimited by explicit BEGIN/END sentinels (the
# same ones used for project instruction docs). This is unambiguous, so
# user-written content outside the markers — including H1 headings that
# happen to mention "C3" or "Tool Discipline" — is never swallowed.
from services.claude_md import C3_BLOCK_BEGIN, C3_BLOCK_END, merge_c3_block

# C3 section exists — replace it with the latest version
# Find the C3 section boundaries: starts at the marker, ends at next # heading or EOF
start = existing.index(_GLOBAL_CLAUDE_MD_MARKER)
# Find the next top-level heading after the C3 section
rest = existing[start + len(_GLOBAL_CLAUDE_MD_MARKER):]
lines_after = rest.split("\n")
end_offset = len(rest) # default: to EOF
running = 0
for line in lines_after:
running += len(line) + 1
# A top-level heading that's NOT part of C3's sub-headings
if line.startswith("# ") and "C3" not in line and "Tool Discipline" not in line:
end_offset = running - len(line) - 1
break

end = start + len(_GLOBAL_CLAUDE_MD_MARKER) + end_offset
before = existing[:start].rstrip()
after = existing[end:].lstrip()
wrapped = f"{C3_BLOCK_BEGIN}\n{_GLOBAL_CLAUDE_MD_CONTENT.strip()}\n{C3_BLOCK_END}"

parts = []
if before:
parts.append(before)
parts.append(_GLOBAL_CLAUDE_MD_CONTENT.strip())
if after:
parts.append(after)
# Markers already present → surgical, marker-bounded replacement.
if C3_BLOCK_BEGIN in existing:
global_md.write_text(merge_c3_block(existing, wrapped), encoding="utf-8")
print(f"Updated {global_md} (refreshed C3 enforcement)")
return

global_md.write_text("\n\n".join(parts) + "\n", encoding="utf-8")
print(f"Updated {global_md} (refreshed C3 enforcement)")
# Legacy marker-less C3 region → one-time migration into the marked block.
# Bound the region from the legacy heading to the NEXT top-level (``# ``)
# heading. C3's own content has exactly one H1 (the legacy heading itself),
# so the next H1 reliably marks where user content resumes; we deliberately
# do NOT skip H1s containing "C3"/"Tool Discipline" (the old heuristic did,
# which is what swallowed user headings).
if _GLOBAL_CLAUDE_MD_MARKER in existing:
start = existing.index(_GLOBAL_CLAUDE_MD_MARKER)
rest = existing[start + len(_GLOBAL_CLAUDE_MD_MARKER):]
end_offset = len(rest) # default: to EOF
running = 0
for line in rest.split("\n"):
running += len(line) + 1
if line.startswith("# "):
end_offset = running - len(line) - 1
break
end = start + len(_GLOBAL_CLAUDE_MD_MARKER) + end_offset
before = existing[:start].rstrip()
after = existing[end:].lstrip()
parts = [p for p in (before, wrapped, after) if p]
global_md.write_text("\n\n".join(parts) + "\n", encoding="utf-8")
print(f"Updated {global_md} (migrated C3 enforcement to markers)")
return

# User has their own CLAUDE.md with no C3 content — append the marked block.
merged = existing.rstrip() + "\n\n" + wrapped + "\n"
global_md.write_text(merged, encoding="utf-8")
print(f"Updated {global_md} (appended C3 enforcement)")


def _instruction_documents_for_project() -> list[tuple[str, str]]:
Expand Down Expand Up @@ -5019,13 +5031,18 @@ def cmd_install_mcp(args):
glob_matcher = "find_files"
edit_matcher = "edit_file"
write_matcher = "write_file"
# Gemini has no MultiEdit / NotebookEdit equivalents.
extra_edit_matchers = []
else:
shell_matcher = "Bash"
read_matcher = "Read"
grep_matcher = "Grep"
glob_matcher = "Glob"
edit_matcher = "Edit"
write_matcher = "Write"
# Claude Code also exposes MultiEdit (batch edits) and NotebookEdit;
# both bypass enforcement/logging unless their matchers are registered.
extra_edit_matchers = ["MultiEdit", "NotebookEdit"]

# ── PostToolUse hooks ──
desired_post_hooks = [
Expand Down Expand Up @@ -5120,6 +5137,10 @@ def cmd_install_mcp(args):
"matcher": write_matcher,
"hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]
},
*[
{"matcher": m, "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]}
for m in extra_edit_matchers
],
]

# ── PreToolUse hooks (enforcement — blocks native tools without prior c3_*) ──
Expand All @@ -5144,6 +5165,10 @@ def cmd_install_mcp(args):
"matcher": write_matcher,
"hooks": [{"type": "command", "command": hook_enforce_cmd}]
},
*[
{"matcher": m, "hooks": [{"type": "command", "command": hook_enforce_cmd}]}
for m in extra_edit_matchers
],
]

# Merge: replace existing C3 hooks (so re-running install-mcp updates commands),
Expand Down
12 changes: 9 additions & 3 deletions cli/hook_edit_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import json
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path

Expand All @@ -25,7 +26,7 @@
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
".rb", ".c", ".cpp", ".h", ".cs", ".html", ".css",
".json", ".yaml", ".yml", ".toml", ".sql", ".md", ".txt",
".sh", ".bat", ".ps1", ".r",
".sh", ".bat", ".ps1", ".r", ".ipynb",
}

# How many tail lines to scan for version/seq (avoids full-file parse)
Expand Down Expand Up @@ -95,8 +96,11 @@ def _next_seq(ledger_file: Path, now: datetime) -> int:
continue
eid = entry.get("id", "")
if eid.startswith(prefix):
# ids may carry a random suffix ("..._001_a1b2"); take the leading
# numeric run after the prefix so same-second seq counting survives.
seq_part = eid[len(prefix):].split("_", 1)[0]
try:
max_seq = max(max_seq, int(eid[len(prefix):]))
max_seq = max(max_seq, int(seq_part))
except ValueError:
pass
return max_seq + 1
Expand Down Expand Up @@ -172,7 +176,9 @@ def main():
git_pending = tracking_level != "minimal"

entry = {
"id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}",
# Random suffix prevents id collisions when the hook process and the
# server process (services/edit_ledger.py) write within the same second.
"id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}_{uuid.uuid4().hex[:4]}",
"timestamp": now.isoformat(),
"session_id": "",
"file": rel,
Expand Down
10 changes: 9 additions & 1 deletion cli/hook_edit_unlock.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@

sys.path.insert(0, str(Path(__file__).parent.parent))

from cli._hook_utils import emit_additional_context, log_hook_error # noqa: E402
from cli._hook_utils import ( # noqa: E402
emit_additional_context,
log_hook_error,
record_json_unlocks,
)

EDITABLE_EXTS = {
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
Expand Down Expand Up @@ -144,6 +148,10 @@ def main():
except Exception:
pass

# Fix 2: also write the .json unlock map — the .txt list above is read
# by NO hook; hook_pretool_enforce.py only consults unlocked_files.json.
record_json_unlocks(editable)

# Emit batched nudge with all pending files
# Prefer c3_edit (no unlock needed). Native Edit is also unlocked via sticky file set.
if len(pending) == 1:
Expand Down
Loading
Loading