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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ 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.148.0] - 2026-09-23

### Added — loosen a built-in guard for a while, or for one session

A lease loosens one built-in guard to `confirm` or `allow` for 1 minute to
8 hours, for every session or for one agent session, in one project or all of
them. The permanent mode is never touched, so when the lease ends the guard is
back where it was with nothing to undo.

- `POST /api/hub/access/builtin/lease` mints one. Leases only loosen, so every
mint needs a C3 Desk client token and the glob typed back. A lease that would
change nothing is refused.
- `POST /api/hub/access/builtin/lease/revoke` ends one early, with no token
needed.
- `GET /api/hub/access/builtin` lists live leases, and the Hub's Built-in
guards panel shows them with a countdown and a Revoke button.

Leases live in `~/.c3/builtin_leases.json` and are two-key, like modes. A row
counts only while the keyring holds the sha256 of that exact row, so a row
written, edited or copied back by hand is ignored. A lease stricter than the
standing mode is ignored too: leases never tighten.

A per-session lease needs the evaluating process to know its session. The
PreToolUse hook takes it from the payload, and the MCP server binds it before
each tool call (`access_guard.bind_session`). The Hub, CLI and mobile API have
no session, so only all-session leases apply there.

## [2.147.0] - 2026-09-23

### Added — built-in guards can be changed from the Hub and C3 Desk
Expand Down
2 changes: 1 addition & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
# Config
CONFIG_DIR = ".c3"
CONFIG_FILE = ".c3/config.json"
__version__ = "2.147.0"
__version__ = "2.148.0"

# The PreToolUse matcher for native subagent calls (installer and hub migration).
AGENT_MATCHER = "Agent|Task"
Expand Down
1 change: 1 addition & 0 deletions cli/hook_access_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ def run(payload: dict, project_path: Path | None = None,
tool_input = payload.get("tool_input", {}) or {}
base = str(project_path if project_path is not None else Path.cwd())
session_id = str(payload.get("session_id") or "")
ag.bind_session(session_id)

if tool in _WRITE_TOOLS or tool in _READ_TOOLS:
fp = _target(tool_input)
Expand Down
57 changes: 56 additions & 1 deletion cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3896,7 +3896,9 @@ def api_hub_access_builtin():
row["error"] = str(e)
if row["modes"] or row["error"]:
projects.append(row)
return jsonify({"guards": guards, "projects": projects})
from services import builtin_leases
return jsonify({"guards": guards, "projects": projects,
"leases": builtin_leases.active()})


@app.route("/api/hub/access/builtin/mode", methods=["POST"])
Expand Down Expand Up @@ -3948,6 +3950,59 @@ def api_hub_access_builtin_mode():
return jsonify({**result, "loosened": new < old})


@app.route("/api/hub/access/builtin/lease", methods=["POST"])
def api_hub_access_builtin_lease():
"""Loosen a builtin for a while: every session, or one.

Body: ``{scope, path?, glob, mode, ttl_s, session_id?, confirm}``. A
lease only ever loosens, so it always needs a Desk client token and the
glob typed back, and a lease no looser than the live mode is a 400
rather than a row that would never apply. ``ttl_s`` is 60 s to 8 h.
"""
from services import builtin_leases
data = request.get_json(silent=True) or {}
scope, project, err = _hub_access_target(data)
if err:
return err
who = _hub_human()
if who is None:
return jsonify({"error": "a temporary built-in change needs C3 Desk",
"needs_human": True}), 403
canon = access_guard._norm_builtin(data.get("glob"))
mode = str(data.get("mode") or "").strip()
if access_guard._norm_builtin(data.get("confirm")) != canon or not canon:
return jsonify({"error": "a temporary change weakens protection",
"needs_confirmation": True, "confirm_with": canon}), 400
try:
live = access_guard.effective_builtin_modes(project or ".") \
if scope == "project" else access_guard._scope_modes("global")
if access_guard.builtin_strictness(canon, mode) >= \
access_guard.builtin_strictness(canon, live.get(canon, "default")):
return jsonify({"error": f"{canon} is already {live.get(canon, 'default')}"
f" — a {mode} lease would change nothing"}), 400
row = builtin_leases.mint(
canon, mode, scope=scope, project_path=project or ".",
session_id=str(data.get("session_id") or ""),
ttl_s=data.get("ttl_s"), created_by=who["client_id"])
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
_hub_access_audit(f"builtin_lease_{mode}", canon, "builtin", scope, project)
return jsonify({"lease": row})


@app.route("/api/hub/access/builtin/lease/revoke", methods=["POST"])
def api_hub_access_builtin_lease_revoke():
"""End a lease now. Tightening, so no token. Body: ``{id}``."""
from services import builtin_leases
lease_id = str((request.get_json(silent=True) or {}).get("id") or "")
gone = builtin_leases.revoke(lease_id) if lease_id else None
if gone is None:
return jsonify({"error": "unknown or already-expired lease"}), 404
_hub_access_audit("builtin_lease_revoke", gone["glob"], "builtin",
gone["scope"], gone.get("project_path") or None)
return jsonify({"revoked": lease_id})


# ── Project management: tasks / milestones / notes (v2.45.0) ──────────────
# Direct TaskStore per request (reload-per-op store — no runtime build needed
# for mutations); every write audited to the target project's activity log.
Expand Down
32 changes: 32 additions & 0 deletions cli/hub_ui/components/hub_access.js
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ function AccBuiltinPanel() {
catch (e) { setErr(apiErr(e)); }
}, []);
useEffect(() => { load(); }, [load]);
usePoll(load, 15000);

const setMode = async (g, mode) => {
setBusy(g.glob); setNeeds(null);
Expand All @@ -420,7 +421,18 @@ function AccBuiltinPanel() {
load();
};

const revoke = async (l) => {
setBusy(l.id);
try {
await api.post('/api/hub/access/builtin/lease/revoke', { id: l.id });
notify(`Ended the ${l.mode} lease on ${l.glob}`, 'ok');
} catch (e) { notify(apiErr(e), 'err'); }
setBusy('');
load();
};

const projects = (data && data.projects) || [];
const leases = (data && data.leases) || [];
return (
<div style={{
background: T.surface, border: `1px solid ${T.border}`, borderRadius: 10,
Expand Down Expand Up @@ -456,6 +468,26 @@ function AccBuiltinPanel() {
))}
</div>
)}
{leases.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: 1, textTransform: 'uppercase', color: T.warn }}>
temporary changes
</div>
{leases.map(l => (
<div key={l.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11.5 }}>
<span className="mono" style={{ color: T.text }}>{l.glob}</span>
<span style={{ color: T.warn }}>{l.mode}</span>
<span style={{ color: T.textMuted }}>
{l.scope === 'project' ? l.project_path : 'all projects'}
{' · '}{l.session_id ? <>session <span className="mono">{l.session_id.slice(0, 8)}</span></> : 'all sessions'}
{' · '}ends in {accExpiresIn(l.expires_at) || '—'}
</span>
<div style={{ flex: 1 }} />
<Btn variant="ghost" disabled={busy === l.id} onClick={() => revoke(l)}>Revoke</Btn>
</div>
))}
</div>
)}
{projects.length > 0 && (
<div style={{ marginTop: 8, fontSize: 11, color: T.textDim }}>
Set per project:{' '}
Expand Down
4 changes: 3 additions & 1 deletion cli/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@

from fastmcp import Context, FastMCP

from cli.tools import _grants
from core.host import resolve_host
from core.ide import get_profile
from services import delegate_hints
from services import access_guard, delegate_hints
from services.auto_memory import AutoMemory
from services.context_snapshot import ContextSnapshot
from services.runtime import C3Runtime, build_runtime, start_runtime, stop_runtime
Expand Down Expand Up @@ -508,6 +509,7 @@ def _bg():
def _svc(ctx: Context) -> C3Runtime:
rt = ctx.request_context.lifespan_context
_ensure_repo_map_once(rt)
access_guard.bind_session(_grants.session_id(rt))
return rt


Expand Down
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.147.0"
version = "2.148.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
39 changes: 35 additions & 4 deletions services/access_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@
# (or, worse, silently disable one).
_ACCESS_KEYRING_SERVICE = "c3-access"

# Temporary and per-session builtin modes (services/builtin_leases.py), in
# ~/.c3. Named here so the hot path can skip the lease module when no file
# exists.
LEASES_FILE = "builtin_leases.json"

# The agent session this process evaluates for; see bind_session().
_SESSION_ID = ""

# Mask presets (docs/mask-guard.md §4). Names + param schema live here so the
# hook subprocess can validate config without importing the transform engines;
# the engines themselves are in services/mask_presets.py.
Expand Down Expand Up @@ -600,10 +608,33 @@ def effective_builtin_modes(project_path: str = ".") -> dict:
"""
out = _scope_modes("global")
proj_base = _scope_base("project", project_path)
if proj_base is None or proj_base == _global_base():
return out # the home directory IS the project; one scope, not two
out.update(_scope_modes("project", project_path))
return out
# When the home directory IS the project there is one scope, not two.
if proj_base is not None and proj_base != _global_base():
out.update(_scope_modes("project", project_path))
return _lease_overlay(out, project_path)


def bind_session(session_id: str) -> None:
"""Name the agent session this process evaluates for, so leases bound to
that session apply. The hook binds per call from its payload; the MCP
server binds before each tool call. Unbound, only all-session leases
apply."""
global _SESSION_ID
_SESSION_ID = str(session_id or "")


def _lease_overlay(modes: dict, project_path: str) -> dict:
"""*modes* with live leases applied. A lease only loosens: one that is
stricter than the standing mode is ignored."""
home = _global_base()
if home is None or not (home / ".c3" / LEASES_FILE).is_file():
return modes
from services import builtin_leases # noqa: PLC0415 — only when leases exist
for glob, mode in builtin_leases.live_modes(project_path, _SESSION_ID).items():
if builtin_strictness(glob, mode) < builtin_strictness(
glob, modes.get(glob, "default")):
modes[glob] = mode
return modes


def builtin_mode_realms(project_path: str = ".") -> dict:
Expand Down
Loading
Loading