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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.147.0] - 2026-09-23

### Added — built-in guards can be changed from the Hub and C3 Desk

Built-in guards (`**/.env*`, `**/.git/**`, the agent-config tier and the rest)
could only be changed with `c3 access builtin mode`. The Hub now reads every
guard's live mode, in the global realm and in each project that set its own,
and can change it:

- `GET /api/hub/access/builtin` lists each guard's global mode, the projects
with a mode of their own, and a 0–3 strictness per mode so a client can tell
loosening from tightening.
- `POST /api/hub/access/builtin/mode` sets one. A mode at least as strict as
the live one applies directly. A looser one needs a C3 Desk client token
(403 `needs_human` otherwise, with the CLI command to run instead) and the
glob typed back (400 `needs_confirmation`).

The token requirement exists because loopback and the CSRF guard stop a
browser, not a process on the machine: an agent can POST to the Hub. Desk
tokens are minted with `bootstrap.key`, which agents cannot read. Phone tokens
are refused, so the mobile API still cannot loosen a built-in guard.

The Hub's Access tab gains a Built-in guards panel that tightens in place and
shows the CLI command for loosening.

## [2.146.0] - 2026-09-22

### Changed — the Hub's cross-project pages stop re-reading every activity log
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.146.0"
__version__ = "2.147.0"

# The PreToolUse matcher for native subagent calls (installer and hub migration).
AGENT_MATCHER = "Agent|Task"
Expand Down
109 changes: 109 additions & 0 deletions cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3839,6 +3839,115 @@ def api_hub_access_rule_remove():
return jsonify(result)


# ── Builtin guard modes (v2.147.0) ─────────────────────────────────────────

def _hub_human():
"""The Desk client behind ``Authorization: Bearer``, else ``None``.

Loopback and the CSRF guard stop a browser, not a process on this
machine: an agent can POST here as easily as a person. A Desk client
token is minted only with ``bootstrap.key``, which Access Guard denies
to agents, and is stored as a hash, so presenting one is the proof a
loosening builtin change needs. A phone token does not count: builtin
guards are not loosened from a device that leaves the building.
"""
from oracle.services import client_tokens
scheme, _, token = (request.headers.get("Authorization") or "").partition(" ")
if scheme.lower() != "bearer" or not token.strip():
return None
who = client_tokens.principal_for(token.strip())
return who if who and who.get("kind") == "desk" else None


@app.route("/api/hub/access/builtin", methods=["GET"])
def api_hub_access_builtin():
"""Every Tier-1 builtin with its live global mode, and each project's own.

Modes are read through the keyring (config and attestation must agree),
so a mode listed here is one evaluation honours. ``projects`` holds only
projects that set a mode of their own, or could not be read.
``strictness`` ranks each mode 0 (allow) to 3 (full deny), so a client
can tell loosening from tightening without knowing the tiers.
"""
try:
global_modes = access_guard._scope_modes("global")
except Exception as e:
return jsonify({"error": str(e)}), 500
home = access_guard._global_base()
guards = []
for glob in access_guard.DISABLEABLE_BUILTINS:
canon = access_guard._norm_builtin(glob)
guards.append({
"glob": canon,
"global": global_modes.get(canon, "default"),
"strictness": {m: access_guard.builtin_strictness(canon, m)
for m in (*access_guard.BUILTIN_MODES, "default")},
})
projects = []
for p in _pm().list_registered():
ppath = str(p.get("path") or "")
row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
"modes": {}, "error": None}
try:
base = Path(ppath).resolve()
if (base / ".c3").is_dir() and base != home:
row["modes"] = access_guard._scope_modes("project", ppath)
except Exception as e:
row["error"] = str(e)
if row["modes"] or row["error"]:
projects.append(row)
return jsonify({"guards": guards, "projects": projects})


@app.route("/api/hub/access/builtin/mode", methods=["POST"])
def api_hub_access_builtin_mode():
"""Set a Tier-1 builtin's mode in the global realm or one project's.

Body: ``{scope, path?, glob, mode, confirm?}``. A mode at least as strict
as the live one needs nothing more. A looser one needs a Desk client
token (403 ``needs_human`` without it) and ``confirm`` equal to the glob
(400 ``needs_confirmation``). A project is judged against what its
agents see today, which is the global mode unless it set its own.
"""
data = request.get_json(silent=True) or {}
scope, project, err = _hub_access_target(data)
if err:
return err
glob = str(data.get("glob") or "")
mode = str(data.get("mode") or "").strip()
canon = access_guard._norm_builtin(glob)
try:
new = access_guard.builtin_strictness(canon, mode)
live = (access_guard._scope_modes("global") if scope == "global"
else access_guard.effective_builtin_modes(project))
old = access_guard.builtin_strictness(canon, live.get(canon, "default"))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if new < old:
if _hub_human() is None:
return jsonify({
"error": "loosening a built-in guard needs C3 Desk",
"needs_human": True,
"command": f'c3 access builtin mode "{canon}" {mode}'
+ (f' --project --path "{project}"'
if scope == "project" else ""),
}), 403
if access_guard._norm_builtin(data.get("confirm")) != canon:
return jsonify({
"error": "loosening a built-in guard weakens protection",
"needs_confirmation": True, "confirm_with": canon,
}), 400
try:
result = access_guard.set_builtin_mode(canon, mode, scope,
project or ".")
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if result["changed"]:
_hub_access_audit(f"builtin_mode_{result['mode']}", result["glob"],
"builtin", scope, project)
return jsonify({**result, "loosened": new < old})


# ── 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
89 changes: 87 additions & 2 deletions cli/hub_ui/components/hub_access.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
// hand — the server enforces that regardless of what this UI believes);
// - a request that lapsed while the page showed it refreshes to its real
// status (the decide route answers 409), never silently mints a grant;
// - rule MUTATION stays on the per-project server and `c3 access` — this
// tab approves and reads, it does not edit policy.
// - path-rule MUTATION stays on the per-project server and `c3 access`.
// Builtin guards may be TIGHTENED here; loosening one needs C3 Desk or
// the CLI, because this page holds no proof that a person clicked.

const ACC_KIND_COLOR = (kind) => (
kind === 'deny' ? T.error : kind === 'mask' ? T.blue
Expand Down Expand Up @@ -387,6 +388,89 @@ function AccRulesPanel({ projects, focus }) {
);
}

// ── Builtin guards ─────────────────────────────────────────────────────────
// The global mode of every Tier-1 builtin. Picking a stricter mode applies
// it; picking a looser one is refused by the server without a Desk token,
// and the panel shows the CLI command instead.
const ACC_BUILTIN_MODES = ['default', 'deny', 'confirm', 'allow'];

function AccBuiltinPanel() {
const [data, setData] = useState(null);
const [err, setErr] = useState('');
const [busy, setBusy] = useState('');
const [needs, setNeeds] = useState(null);

const load = useCallback(async () => {
try { setData(await api.get('/api/hub/access/builtin')); setErr(''); }
catch (e) { setErr(apiErr(e)); }
}, []);
useEffect(() => { load(); }, [load]);

const setMode = async (g, mode) => {
setBusy(g.glob); setNeeds(null);
try {
await api.post('/api/hub/access/builtin/mode', { scope: 'global', glob: g.glob, mode });
notify(`${g.glob} is now ${mode} everywhere`, 'ok');
} catch (e) {
const body = e && e.payload;
if (body && body.needs_human) setNeeds({ glob: g.glob, command: body.command });
else notify(apiErr(e), 'err');
}
setBusy('');
load();
};

const projects = (data && data.projects) || [];
return (
<div style={{
background: T.surface, border: `1px solid ${T.border}`, borderRadius: 10,
padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12.5, fontWeight: 700, color: T.text }}>Built-in guards</div>
<span style={{ fontSize: 10.5, color: T.textDim }}>
global mode — a stricter mode applies here; loosening one needs C3 Desk or the CLI
</span>
</div>
{err && <div style={{ marginTop: 8, fontSize: 11.5, color: T.error }}>{err}</div>}
{needs && (
<div style={{ marginTop: 8, fontSize: 11.5, color: T.warn }}>
Loosening <span className="mono">{needs.glob}</span> needs C3 Desk, or run:{' '}
<span className="mono" style={{ color: T.text }}>{needs.command}</span>
</div>
)}
{data && (
<div style={{ marginTop: 10, display: 'grid', gridTemplateColumns: 'minmax(0,1fr) auto', gap: '4px 12px', alignItems: 'center' }}>
{data.guards.map(g => (
<React.Fragment key={g.glob}>
<span className="mono" style={{ fontSize: 11.5, color: T.text, overflow: 'hidden', textOverflow: 'ellipsis' }}>{g.glob}</span>
<select value={g.global} disabled={busy === g.glob}
onChange={e => setMode(g, e.target.value)}
style={{
background: T.surfaceAlt, color: g.global === 'default' ? T.textMuted : T.warn,
border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 11.5, padding: '2px 6px',
}}>
{ACC_BUILTIN_MODES.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</React.Fragment>
))}
</div>
)}
{projects.length > 0 && (
<div style={{ marginTop: 8, fontSize: 11, color: T.textDim }}>
Set per project:{' '}
{projects.map(p => (
<span key={p.path} className="mono" style={{ marginRight: 10 }}>
{p.name}: {p.error ? `unreadable (${p.error})`
: Object.entries(p.modes).map(([g, m]) => `${g}=${m}`).join(', ')}
</span>
))}
</div>
)}
</div>
);
}

// ── Active grants ──────────────────────────────────────────────────────────
// A rule grant can authorise many calls over hours. The only other place it
// shows up is a line in the agent's own transcript, which is the wrong place
Expand Down Expand Up @@ -762,6 +846,7 @@ function HubAccess({ projects }) {
)}

<AccGrantsPanel projects={projects} />
<AccBuiltinPanel />
<AccRulesPanel projects={projects} focus={ruleFocus} />
{confirmSpec && (
<CredConfirm spec={confirmSpec} onClose={() => setConfirmSpec(null)} />
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.146.0"
version = "2.147.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
25 changes: 25 additions & 0 deletions services/access_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,31 @@ def builtin_mode_realms(project_path: str = ".") -> dict:
return out


_KIND_RANK = {_KIND_CONFIRM: 1, _KIND_READ_ONLY: 2, _KIND_DENY: 3}


def builtin_strictness(glob, mode: str) -> int:
"""How strict *mode* makes a Tier-1 builtin: 0 (allow) to 3 (full deny).

The same mode word means different rules per tier — ``default`` is a full
deny on ``**/.env*`` and read-only on ``**/.git/**`` — so loosening is
only comparable through this. Raises ValueError for a glob that is not
Tier 1 or an unknown mode.
"""
canon = _norm_builtin(glob)
variants = _BUILTIN_VARIANTS.get(canon)
if variants is None:
raise ValueError(f"'{_norm_glob(glob)}' is not a disableable builtin "
f"— expected one of: {', '.join(DISABLEABLE_BUILTINS)}")
if mode not in BUILTIN_MODES + ("default",):
raise ValueError(f"unknown mode '{mode}' — expected one of: "
f"{', '.join(BUILTIN_MODES + ('default',))}")
if mode == "allow":
return 0
return _KIND_RANK[variants[mode if mode in ("deny", "confirm")
else "default"].kind]


def disabled_builtins(project_path: str = ".") -> frozenset:
"""Canonical globs of builtins that are genuinely OFF right now — the
legacy two-key opt-out plus mode="allow" entries. Callers that only ask
Expand Down
Loading
Loading