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
88 changes: 88 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,94 @@ 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.67.0] - 2026-07-31

### Added — The Discipline tab grows search, evidence, and controls (Hub)

v2.66 shipped the knob; this release ships the workbench around it. Most of
what landed here was capability the backend already had and no surface
exposed: the raw denial-event log had a public reader nothing called, the
policy layer accepted `scope="global"` that no route passed, `signal_ttl_s`
and `blocked_tools` were fully resolved and validated but had no write
surface. The Hub tab now reaches all of it.

- **Search, filter, sort.** A free-text filter over project name / path /
mode / tier (`/` focuses it), chips for `Strict` / `Advisory` / `Off` /
`Has denials` / `Attention` (warnings, unreadable policies, tier drift),
and sorting by name, denial count, or mode. The tab now polls every 5s
like Locks — and never mid-interaction: a refresh while you type, or with
a confirm open, would reorder cards under your cursor.
- **Raw denial-event search.** Expand a project's denials and search the
actual events, not just the coalesced summary: AND'd substrings over
path/rule/tool, layer chips, click a session id to filter to that session,
`all events` to browse newest-first. Backed by
`access_telemetry.search_events` via
`GET /api/enforcement/denials/search` (project) and
`GET /api/projects/enforcement/denials/search` (Hub) — limit 200 (cap
500), `matched` keeps counting past the cap so truncation is visible, and
the rotated `.jsonl.1` is included. Aggregate rows now show last-hit
recency and session counts, which the server always sent and the UI
always dropped.
- **Global default card.** The `~/.c3` fallback finally has a UI: its own
mode picker and TTL editor, `NOT SET` when no global section exists (which
is not the same claim as `strict`, and the card keeps them apart). The
POST routes accept `scope: "global"`; the CLI's `c3 enforce --global` is
no longer the only way.
- **TTL and blocked-tools editors.** Per project and on the global card.
Both post mode-less bodies routed through a new
`enforcement_policy.set_fields`, which never touches `mode`/`set_by` — a
TTL tweak cannot turn a tier-derived choice into a `user` one — and
**refuses to create** an `enforcement` section, because a mode-less
section coerces to `strict` and would silently shadow an inherited
`advisory`. The editors are enabled only when the row's policy actually
comes from the project scope, and say why when it does not.
- **Bulk apply.** `select` puts the list in checkbox mode; a sticky bar
applies one mode to every selected project after a single confirm that
spells out what `off` does and does not switch off. Writes are sequential
and audited per project; failures are named, not swallowed.
- **Discipline in the drill panel.** Clicking a project name now opens the
drill on a new Discipline tab — the same controls scoped to one project,
the full 12-row aggregate with fixes, and the event search — instead of
dropping you on Overview and losing the thread.

### Fixed

- **The Discipline tab never persisted as the active view.** The Hub's
`main_view` whitelist was missing `enforce`, so selecting the tab 400'd
silently (the client swallows config-save errors) and every reload dropped
you back on Projects. Two-line fix, pinned by a test that mirrors the
Locks one.
- Hub error banners in the Discipline tab now surface the server's actual
error (`apiErr`) instead of a bare `HTTP 500`.
- The tab now consumes the server's authoritative mode list, help strings,
and tier map instead of a hardcoded client copy that could drift.

### Changed

- `signal_ttl_s` is now **validated on write** (30…86400 → HTTP 400 /
`ValueError`) instead of silently written and clamped at read time.
Read-time clamping stays, for hand-edited files.
- `enforcement_policy.set_mode` accepts `blocked_tools`, validated against
`GOVERNABLE_TOOLS` and written in the same atomic write as the mode.
- `POST /api/projects/enforcement` body is now
`{path?, scope?, mode?, signal_ttl_s?, blocked_tools?}`; project scope
(the default) keeps its old contract exactly. Global-scope writes are not
audited to a project ledger — there is no target project; the
`~/.c3/config.json` write is itself the record.
- The `enforcement` config section remains deliberately excluded from the
generic Config editor's write whitelist — the dedicated route is the only
write path, so validation and provenance rules cannot be bypassed.
- The aggregate endpoints accept `?session=` to narrow to one session
(`c3 access stats --session` had this; the routes now do too).

Not in this release, evaluated and deferred: outcome telemetry (logging
advisory nudges/allows for an effectiveness view — hot hook path, 10-100×
event volume, needs its own perf-careful pass) and NotebookEdit governance
(a file-writing tool the discipline hook currently does not govern at all —
an enforcement-semantics change with its own tests).

Full reference: `docs/enforcement.md`.

## [2.66.0] - 2026-07-31

### Added — Tool discipline is now a knob you can turn (`c3 enforce`)
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.66.0"
__version__ = "2.67.0"


def _compress_file_cli(compressor, path, mode="smart", **kw):
Expand Down
207 changes: 170 additions & 37 deletions cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,9 +435,9 @@ def api_hub_config_set():
cfg["projects_view"] = projects_view
if "main_view" in data:
main_view = str(data["main_view"]).strip().lower()
if main_view not in {"projects", "board", "creds", "locks"}:
if main_view not in {"projects", "board", "creds", "locks", "enforce"}:
return jsonify({"error": "main_view must be 'projects', 'board', "
"'creds' or 'locks'"}), 400
"'creds', 'locks' or 'enforce'"}), 400
cfg["main_view"] = main_view
if "oracle_url" in data:
cfg["oracle_url"] = str(data["oracle_url"]).strip()
Expand Down Expand Up @@ -1723,6 +1723,9 @@ def api_search_global():
_CONFIG_READ_SECTIONS = ("hybrid", "agents", "delegate", "proxy", "mcp", "bitbucket", "meta",
"memory_llm")
_CONFIG_WRITE_SECTIONS = ("hybrid", "agents", "delegate", "proxy", "mcp", "meta", "memory_llm")
# "enforcement" is deliberately NOT writable here: the generic deep-merge would
# bypass mode/ttl/blocked_tools validation and the set_by provenance rules.
# POST /api/projects/enforcement is the only write path.
# api_key: secrets never transit the hub or land in config.json — the Ollama
# cloud key lives in the OS keyring (project Settings UI / OLLAMA_API_KEY env).
_CONFIG_REFUSED_KEYS = ("version", "project_path", "permission_tier", "subprojects", "parent",
Expand Down Expand Up @@ -2166,6 +2169,7 @@ def api_hub_enforcement_overview():
row["set_by"] = policy.set_by
row["signal_ttl_s"] = policy.signal_ttl_s
row["warnings"] = list(policy.warnings)
row["blocked_tools"] = sorted(policy.blocked_tools)

cfg_path = Path(ppath) / ".c3" / "config.json"
try:
Expand All @@ -2188,12 +2192,28 @@ def api_hub_enforcement_overview():
row["error"] = str(e)
rows.append(row)

# The global (~/.c3) section, shown as its own card. A corrupt global
# config must not blank the tab — report the error inside the object.
try:
g = ep.resolve_global()
global_policy = {
"configured": g.scope == "global",
"mode": g.mode or None,
"set_by": g.set_by,
"signal_ttl_s": g.signal_ttl_s,
"blocked_tools": sorted(g.blocked_tools),
"warnings": list(g.warnings),
}
except Exception as e:
global_policy = {"configured": False, "mode": None, "error": str(e)}

return jsonify({
"projects": rows,
"modes": [{"id": m, "help": ep.MODE_HELP[m]} for m in ep.MODES],
"default_mode": ep.DEFAULT_MODE,
"tier_map": ep.TIER_TO_MODE,
"totals": totals,
"global_policy": global_policy,
# Stated so the tab can never imply that turning discipline down also
# turns the security boundaries down. Mirrors docs/enforcement.md.
"coverage_note": (
Expand All @@ -2206,58 +2226,171 @@ def api_hub_enforcement_overview():
})


@app.route("/api/projects/enforcement", methods=["GET"])
def api_projects_enforcement_get():
"""One project's effective policy plus its denial evidence.

Hub-side mirror of the per-project GET /api/enforcement — feeds the drill
panel's Discipline tab. Optional ``?session=`` narrows the denial
aggregate to one session id (full id; the event search route does prefix
matching for the UI's 8-char short ids).
"""
from services import access_telemetry as at
from services import enforcement_policy as ep

path = (request.args.get("path") or "").strip()
if not path:
return jsonify({"error": "path is required"}), 400
try:
resolved = _resolve_project_path(path)
except ValueError as e:
return jsonify({"error": str(e)}), 404

policy = ep.resolve(resolved)
try:
cfg = json.loads((Path(resolved) / ".c3" / "config.json")
.read_text(encoding="utf-8"))
tier = str(cfg.get("permission_tier") or "") if isinstance(cfg, dict) else ""
except Exception:
tier = ""
session = (request.args.get("session") or "").strip()
agg = at.aggregate(resolved, session_id=session)

return jsonify({
"mode": policy.mode,
"scope": policy.scope,
"set_by": policy.set_by,
"signal_ttl_s": policy.signal_ttl_s,
"blocked_tools": sorted(policy.blocked_tools),
"warnings": list(policy.warnings),
"tier": tier,
"tier_implies": ep.derive_from_tier(tier) if tier else "",
"default_mode": ep.DEFAULT_MODE,
"modes": [{"id": m, "help": ep.MODE_HELP[m]} for m in ep.MODES],
"denials": {
"total": agg["total"],
"by_layer": agg["by_layer"],
"rows": [{**r, "fix": at.suggest(r)} for r in agg["rows"][:12]],
},
})


@app.route("/api/projects/enforcement", methods=["POST"])
def api_projects_enforcement_set():
"""Set one project's tool-discipline mode. Human-only, audited on target.

Always writes ``set_by='user'``: a change made deliberately in the Hub is
an explicit choice and must survive a later permission-tier change, the
same as `c3 enforce`.
"""Set tool-discipline policy fields. Human-only, audited on target.

Body: ``{path?, scope?, mode?, signal_ttl_s?, blocked_tools?}``.
``scope`` defaults to project (``path`` required); ``global`` writes
``~/.c3`` and ignores ``path``. A body with ``mode`` goes through
``set_mode`` — always ``set_by='user'``: a change made deliberately in
the Hub is an explicit choice and must survive a later permission-tier
change, the same as `c3 enforce`. A mode-less body goes through
``set_fields``, which never touches ``mode``/``set_by``.
"""
from services import enforcement_policy as ep

data = request.get_json(force=True) or {}
path = (data.get("path") or "").strip()
scope = (data.get("scope") or "project").strip().lower()
mode = (data.get("mode") or "").strip().lower()
ttl = data.get("signal_ttl_s")
if not path or not mode:
return jsonify({"error": "path and mode are required"}), 400
try:
resolved = _resolve_project_path(path)
except ValueError as e:
return jsonify({"error": str(e)}), 404
blocked = data.get("blocked_tools")
if scope not in ("project", "global"):
return jsonify({"error": "scope must be 'project' or 'global'"}), 400
if scope == "project":
if not path:
return jsonify({"error": "path is required for project scope"}), 400
try:
resolved = _resolve_project_path(path)
except ValueError as e:
return jsonify({"error": str(e)}), 404
else:
resolved = "."

try:
result = ep.set_mode(mode, resolved, set_by=ep.SET_BY_USER,
scope="project",
signal_ttl_s=int(ttl) if ttl else None)
if mode:
result = ep.set_mode(mode, resolved, set_by=ep.SET_BY_USER,
scope=scope, signal_ttl_s=ttl,
blocked_tools=blocked)
elif ttl is not None or blocked is not None:
result = ep.set_fields(resolved, scope=scope,
signal_ttl_s=ttl, blocked_tools=blocked)
else:
return jsonify({"error": "nothing to set — pass mode, "
"signal_ttl_s or blocked_tools"}), 400
except (ValueError, TypeError) as e:
return jsonify({"error": str(e)}), 400

try:
from services.activity_log import ActivityLog
ActivityLog(resolved).log("access_action", {
"kind": "enforcement", "action": "set_mode",
"mode": result["mode"], "previous": result.get("previous", ""),
"scope": result["scope"], "via": "hub"})
except Exception:
pass
try:
from services.edit_ledger import EditLedger
EditLedger(resolved).log_edit(
file=f"enforcement://{result['scope']}",
change_type="enforcement_set_mode",
summary=(f"tool discipline {result.get('previous') or 'default'} "
f"-> {result['mode']} via the Hub"),
tags=["enforcement", "access"],
detail={"kind": "enforcement", "mode": result["mode"],
"previous": result.get("previous", ""),
"scope": result["scope"], "via": "hub"})
except Exception:
pass
# Audit on the target project. Global scope has no target project to
# audit into — the ~/.c3/config.json write is itself the record.
if scope == "project":
action = "set_mode" if mode else "set_fields"
detail = {"kind": "enforcement", "action": action,
"mode": result.get("mode", ""),
"previous": result.get("previous", ""),
"scope": result["scope"], "via": "hub"}
if "signal_ttl_s" in result:
detail["signal_ttl_s"] = result["signal_ttl_s"]
if "blocked_tools" in result:
detail["blocked_tools"] = result["blocked_tools"]
if mode:
summary = (f"tool discipline {result.get('previous') or 'default'} "
f"-> {result['mode']} via the Hub")
else:
parts = []
if "signal_ttl_s" in result:
parts.append(f"signal_ttl_s={result['signal_ttl_s']}")
if "blocked_tools" in result:
parts.append("blocked_tools="
+ ",".join(result["blocked_tools"] or ["<none>"]))
summary = "tool discipline " + ", ".join(parts) + " via the Hub"
try:
from services.activity_log import ActivityLog
ActivityLog(resolved).log("access_action", dict(detail))
except Exception:
pass
try:
from services.edit_ledger import EditLedger
EditLedger(resolved).log_edit(
file=f"enforcement://{result['scope']}",
change_type=f"enforcement_{action}",
summary=summary,
tags=["enforcement", "access"],
detail=detail)
except Exception:
pass
return jsonify(result)


@app.route("/api/projects/enforcement/denials/search", methods=["GET"])
def api_projects_enforcement_denials_search():
"""Search one project's raw denial events. Read-only; newest first.

Params: ``path`` (required), ``q`` (AND'd case-insensitive substrings over
path/rule/tool), ``layer``, ``tool`` (exact), ``session`` (prefix — the UI
shows 8-char short ids), ``since`` (ISO-8601; events store
second-resolution UTC isoformat, so plain string comparison is correct),
``limit`` (default 200, cap 500).
"""
from services import access_telemetry as at

path = (request.args.get("path") or "").strip()
if not path:
return jsonify({"error": "path is required"}), 400
try:
resolved = _resolve_project_path(path)
except ValueError as e:
return jsonify({"error": str(e)}), 404
return jsonify(at.search_events(
resolved,
q=request.args.get("q") or "",
layer=request.args.get("layer") or "",
tool=request.args.get("tool") or "",
session=request.args.get("session") or "",
since=request.args.get("since") or "",
limit=request.args.get("limit") or 200))


@app.route("/api/projects/enforcement/denials", methods=["DELETE"])
def api_projects_enforcement_denials_clear():
"""Reset one project's denial counters (they are diagnostics, not audit)."""
Expand Down
2 changes: 2 additions & 0 deletions cli/hub_ui/components/drill_panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const DRILL_PANEL_TABS = [
['health', 'Health'],
['budget', 'Budget'],
['creds', 'Credentials'],
['discipline', 'Discipline'],
['config', 'Config'],
['mcp', 'MCP'],
];
Expand Down Expand Up @@ -134,6 +135,7 @@ function DrillPanel({ project, tab, setTab, onClose, onChanged, onOpenModal, pro
case 'health': return <DrillHealth project={project} onChanged={onChanged} />;
case 'budget': return <DrillBudget project={project} />;
case 'creds': return <DrillCredentials project={project} />;
case 'discipline': return <DrillDiscipline project={project} />;
case 'config': return <ConfigEditor project={project} />;
case 'mcp': return <McpManager project={project} onChanged={onChanged} />;
default: return <DrillOverview project={project} onChanged={onChanged} setTab={setTab} />;
Expand Down
Loading