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
+
+