diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cbbc0..ec33217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/c3.py b/cli/c3.py index 196a754..690f4e1 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -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" diff --git a/cli/hub_server.py b/cli/hub_server.py index d31bbb9..d0ce930 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -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. diff --git a/cli/hub_ui/components/hub_access.js b/cli/hub_ui/components/hub_access.js index 25d5631..069d75e 100644 --- a/cli/hub_ui/components/hub_access.js +++ b/cli/hub_ui/components/hub_access.js @@ -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 @@ -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 ( +
+
+
Built-in guards
+ + global mode — a stricter mode applies here; loosening one needs C3 Desk or the CLI + +
+ {err &&
{err}
} + {needs && ( +
+ Loosening {needs.glob} needs C3 Desk, or run:{' '} + {needs.command} +
+ )} + {data && ( +
+ {data.guards.map(g => ( + + {g.glob} + + + ))} +
+ )} + {projects.length > 0 && ( +
+ Set per project:{' '} + {projects.map(p => ( + + {p.name}: {p.error ? `unreadable (${p.error})` + : Object.entries(p.modes).map(([g, m]) => `${g}=${m}`).join(', ')} + + ))} +
+ )} +
+ ); +} + // ── 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 @@ -762,6 +846,7 @@ function HubAccess({ projects }) { )} + {confirmSpec && ( setConfirmSpec(null)} /> diff --git a/pyproject.toml b/pyproject.toml index 977dec3..81fbe48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/services/access_guard.py b/services/access_guard.py index 8c664ae..379e151 100644 --- a/services/access_guard.py +++ b/services/access_guard.py @@ -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 diff --git a/tests/test_hub_builtin_routes.py b/tests/test_hub_builtin_routes.py new file mode 100644 index 0000000..e41184b --- /dev/null +++ b/tests/test_hub_builtin_routes.py @@ -0,0 +1,163 @@ +"""Builtin guard modes from the Hub (2.147.0): read every realm, tighten +freely, loosen only with a Desk client token and the glob retyped.""" +from __future__ import annotations + +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +import cli.hub_server as hub_server # noqa: E402 +from oracle.services import client_tokens # noqa: E402 +from services import access_guard as ag # noqa: E402 + +ENV = "**/.env*" +GIT = "**/.git/**" +DESK = {"Authorization": "Bearer desk-token"} +PHONE = {"Authorization": "Bearer phone-token"} + + +class _FakeKeyring: + def __init__(self): + self.store = {} + + def set_password(self, service, account, value): + self.store[(service, account)] = value + + def get_password(self, service, account): + return self.store.get((service, account)) + + +class _StubPM: + def __init__(self, projects): + self._projects = projects + + def list_registered(self): + return self._projects + + +def _principal(token): + return {"desk-token": {"kind": "desk", "client_id": "desk-1"}, + "phone-token": {"kind": "mobile", "client_id": "mobile-1"}}.get(token) + + +class HubBuiltinRoutes(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + root = Path(self._tmp.name) + self.home = root / "home" + (self.home / ".c3").mkdir(parents=True) + self.proj = root / "proj" + (self.proj / ".c3").mkdir(parents=True) + + fake = _FakeKeyring() + mod = types.ModuleType("keyring") + mod.set_password, mod.get_password = fake.set_password, fake.get_password + self._patches = [ + mock.patch.dict(sys.modules, {"keyring": mod}), + mock.patch.object(ag, "_global_base", return_value=self.home), + mock.patch.object(hub_server, "_pm", return_value=_StubPM( + [{"name": "proj", "path": str(self.proj)}])), + mock.patch.object(hub_server, "_resolve_project_path", + side_effect=lambda p: Path(p)), + mock.patch.object(client_tokens, "principal_for", + side_effect=_principal), + ] + for p in self._patches: + p.start() + hub_server.app.config["TESTING"] = True + self.client = hub_server.app.test_client() + + def tearDown(self): + for p in reversed(self._patches): + p.stop() + self._tmp.cleanup() + + def set_mode(self, body, headers=None): + resp = self.client.post("/api/hub/access/builtin/mode", json=body, + headers=headers or {}) + return resp.status_code, resp.get_json() + + def global_mode(self, glob): + data = self.client.get("/api/hub/access/builtin").get_json() + return next(g["global"] for g in data["guards"] if g["glob"] == glob) + + def test_strictness_ranks_the_same_word_differently_per_tier(self): + self.assertEqual(ag.builtin_strictness(ENV, "default"), 3) + self.assertEqual(ag.builtin_strictness(GIT, "default"), 2) + self.assertEqual(ag.builtin_strictness(GIT, "deny"), 3) + self.assertEqual(ag.builtin_strictness(ENV, "confirm"), 1) + self.assertEqual(ag.builtin_strictness(ENV, "allow"), 0) + with self.assertRaises(ValueError): + ag.builtin_strictness("**/.c3/secrets.enc", "allow") + with self.assertRaises(ValueError): + ag.builtin_strictness(ENV, "off") + + def test_tightening_needs_no_token(self): + status, body = self.set_mode({"scope": "global", "glob": GIT, "mode": "deny"}) + self.assertEqual(status, 200) + self.assertFalse(body["loosened"]) + self.assertEqual(self.global_mode(GIT), "deny") + + def test_loosening_without_a_token_is_refused_with_the_cli_command(self): + status, body = self.set_mode({"scope": "global", "glob": ENV, + "mode": "confirm", "confirm": ENV}) + self.assertEqual(status, 403) + self.assertTrue(body["needs_human"]) + self.assertEqual(body["command"], 'c3 access builtin mode "**/.env*" confirm') + self.assertEqual(self.global_mode(ENV), "default") + + def test_a_phone_token_does_not_count_as_desk(self): + status, _ = self.set_mode({"scope": "global", "glob": ENV, "mode": "confirm", + "confirm": ENV}, PHONE) + self.assertEqual(status, 403) + + def test_loosening_with_desk_needs_the_glob_retyped(self): + status, body = self.set_mode({"scope": "global", "glob": ENV, + "mode": "confirm", "confirm": "nope"}, DESK) + self.assertEqual(status, 400) + self.assertEqual(body["confirm_with"], ENV) + self.assertEqual(self.global_mode(ENV), "default") + + def test_loosening_with_desk_and_confirm_applies_and_evaluation_honours_it(self): + status, body = self.set_mode({"scope": "global", "glob": ENV, + "mode": "confirm", "confirm": ENV}, DESK) + self.assertEqual(status, 200) + self.assertTrue(body["loosened"]) + self.assertEqual(self.global_mode(ENV), "confirm") + self.assertEqual(ag.check(str(self.proj / ".env"), "read", + str(self.proj)).kind, "confirm") + + def test_a_project_is_judged_against_what_its_agents_see(self): + self.set_mode({"scope": "global", "glob": ENV, "mode": "allow", + "confirm": ENV}, DESK) + status, _ = self.set_mode({"scope": "project", "path": str(self.proj), + "glob": ENV, "mode": "confirm"}) + self.assertEqual(status, 200) + data = self.client.get("/api/hub/access/builtin").get_json() + self.assertEqual(data["projects"][0]["modes"], {ENV: "confirm"}) + + def test_project_refusal_names_the_project_in_the_command(self): + status, body = self.set_mode({"scope": "project", "path": str(self.proj), + "glob": ENV, "mode": "allow"}) + self.assertEqual(status, 403) + self.assertIn(f'--project --path "{self.proj}"', body["command"]) + + def test_config_without_the_keyring_half_reads_as_default(self): + (self.home / ".c3" / "config.json").write_text( + json.dumps({"access": {"builtin_mode": {ENV: "allow"}}}), encoding="utf-8") + self.assertEqual(self.global_mode(ENV), "default") + + def test_unknown_glob_is_a_400(self): + status, _ = self.set_mode({"scope": "global", "glob": "*.pem", "mode": "deny"}) + self.assertEqual(status, 400) + + +if __name__ == "__main__": + unittest.main()