code-context-control 2.146.0__py3-none-any.whl → 2.149.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/c3.py CHANGED
@@ -91,7 +91,7 @@ console = Console() if HAS_RICH else None
91
91
  # Config
92
92
  CONFIG_DIR = ".c3"
93
93
  CONFIG_FILE = ".c3/config.json"
94
- __version__ = "2.146.0"
94
+ __version__ = "2.149.1"
95
95
 
96
96
  # The PreToolUse matcher for native subagent calls (installer and hub migration).
97
97
  AGENT_MATCHER = "Agent|Task"
cli/hook_access_guard.py CHANGED
@@ -447,6 +447,7 @@ def run(payload: dict, project_path: Path | None = None,
447
447
  tool_input = payload.get("tool_input", {}) or {}
448
448
  base = str(project_path if project_path is not None else Path.cwd())
449
449
  session_id = str(payload.get("session_id") or "")
450
+ ag.bind_session(session_id)
450
451
 
451
452
  if tool in _WRITE_TOOLS or tool in _READ_TOOLS:
452
453
  fp = _target(tool_input)
cli/hub_server.py CHANGED
@@ -72,6 +72,10 @@ _HUB_CONFIG_DEFAULTS = {
72
72
  "projects_view": "list",
73
73
  "main_view": "projects",
74
74
  "oracle_url": "",
75
+ # Per-IDE launch command overrides, e.g. {"claude-code": "yep"} when the
76
+ # agent is started through a wrapper script instead of the stock binary.
77
+ # A project's own `ide_cmd` (projects.json) wins over this.
78
+ "ide_cmds": {},
75
79
  # Minutes of project silence, with no session heartbeat left, before a UI
76
80
  # server that an IDE session launched is stopped. 0 = never reap.
77
81
  "ui_reap_minutes": 30,
@@ -81,6 +85,48 @@ _HUB_CONFIG_DEFAULTS = {
81
85
  _MAIN_VIEWS = {"projects", "board", "ci", "creds", "tokens", "locks", "access",
82
86
  "enforce", "sessions"}
83
87
 
88
+ # Stock command per IDE id, and whether it takes the project path as an
89
+ # argument (GUI) instead of being run inside a terminal at that path.
90
+ _IDE_DEFAULT_CMDS = {
91
+ "claude-code": ("claude", False),
92
+ "claude-app": ("claude-app", True),
93
+ "codex": ("codex", False),
94
+ "grok": ("grok", False),
95
+ "gemini": ("gemini", False),
96
+ "antigravity": ("antigravity", False),
97
+ "vscode": ("code", True),
98
+ "cursor": ("cursor", True),
99
+ }
100
+
101
+
102
+ def _resolve_ide_cmd(ide: str, path: str, custom_cmd: str = "") -> tuple:
103
+ """Resolve the command to launch for `ide` in `path`.
104
+
105
+ Precedence: explicit custom_cmd (this launch) → the project's stored
106
+ `ide_cmd` → hub config `ide_cmds[ide]` → the stock command.
107
+ Returns (cmd, is_gui, error) — error is a message when nothing resolves.
108
+ """
109
+ default_cmd, is_gui = _IDE_DEFAULT_CMDS.get(ide, ("", False))
110
+ if ide != "custom" and not default_cmd:
111
+ return "", False, f"Unknown IDE: {ide}"
112
+ cmd = (custom_cmd or "").strip()
113
+ if not cmd:
114
+ try:
115
+ cmd = _pm().get_ide_cmd(path)
116
+ except Exception:
117
+ cmd = ""
118
+ if not cmd:
119
+ overrides = _read_hub_config().get("ide_cmds") or {}
120
+ if isinstance(overrides, dict):
121
+ cmd = str(overrides.get(ide) or "").strip()
122
+ if not cmd:
123
+ cmd = default_cmd
124
+ if not cmd:
125
+ return "", False, "custom_cmd is required for custom IDE"
126
+ # An overridden command replaces the binary, not the launch style: a
127
+ # wrapper for a terminal CLI is still a terminal CLI.
128
+ return cmd, is_gui, ""
129
+
84
130
  # How often the hub reaps orphaned UI servers and chases autostart projects.
85
131
  _UI_SWEEP_INTERVAL_S = 60
86
132
 
@@ -460,6 +506,15 @@ def api_hub_config_set():
460
506
  cfg["main_view"] = main_view
461
507
  if "oracle_url" in data:
462
508
  cfg["oracle_url"] = str(data["oracle_url"]).strip()
509
+ if "ide_cmds" in data:
510
+ raw = data["ide_cmds"]
511
+ if not isinstance(raw, dict):
512
+ return jsonify({"error": "ide_cmds must be an object"}), 400
513
+ # Empty string = "back to the stock command", so the key is dropped
514
+ # rather than stored blank.
515
+ cfg["ide_cmds"] = {
516
+ str(k): str(v).strip() for k, v in raw.items() if str(v or "").strip()
517
+ }
463
518
  if "sidebar_group" in data:
464
519
  cfg["sidebar_group"] = str(data["sidebar_group"]).strip()
465
520
  if "sidebar_collapsed" in data:
@@ -718,17 +773,11 @@ def api_projects_open():
718
773
 
719
774
  @app.route("/api/projects/launch-ide", methods=["POST"])
720
775
  def api_launch_ide():
721
- """Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?}"""
722
- _IDE_CMDS = {
723
- "claude-code": ("claude", False),
724
- "claude-app": ("claude-app", True),
725
- "codex": ("codex", False),
726
- "grok": ("grok", False),
727
- "gemini": ("gemini", False),
728
- "antigravity": ("antigravity", False),
729
- "vscode": ("code", True),
730
- "cursor": ("cursor", True),
731
- }
776
+ """Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?}
777
+
778
+ The command itself is resolved by _resolve_ide_cmd, so a project or the
779
+ hub can point `claude-code` at a wrapper such as `yep`.
780
+ """
732
781
  try:
733
782
  data = request.get_json(force=True) or {}
734
783
  path_str = (data.get("path") or "").strip()
@@ -744,14 +793,9 @@ def api_launch_ide():
744
793
  if not path.exists():
745
794
  return jsonify({"error": f"Path does not exist: {path_str}"}), 404
746
795
 
747
- if ide == "custom":
748
- if not custom_cmd:
749
- return jsonify({"error": "custom_cmd is required for custom IDE"}), 400
750
- cmd, is_gui = custom_cmd, False
751
- elif ide in _IDE_CMDS:
752
- cmd, is_gui = _IDE_CMDS[ide]
753
- else:
754
- return jsonify({"error": f"Unknown IDE: {ide}"}), 400
796
+ cmd, is_gui, err = _resolve_ide_cmd(ide, str(path), custom_cmd)
797
+ if err:
798
+ return jsonify({"error": err}), 400
755
799
 
756
800
  if is_gui:
757
801
  # GUI IDEs (VS Code, Cursor) accept a path argument directly
@@ -795,7 +839,7 @@ def api_launch_ide():
795
839
 
796
840
  @app.route("/api/projects/update", methods=["POST"])
797
841
  def api_projects_update():
798
- """Update editable project fields (name, tags, notes). Body: {path, name?, tags?, notes?}"""
842
+ """Update editable project fields. Body: {path, name?, tags?, notes?, ide_cmd?}"""
799
843
  data = request.get_json(force=True) or {}
800
844
  path = (data.get("path") or "").strip()
801
845
  if not path:
@@ -812,6 +856,9 @@ def api_projects_update():
812
856
  fields["tags"] = [t.strip() for t in str(raw).split(",") if t.strip()]
813
857
  if "notes" in data:
814
858
  fields["notes"] = str(data["notes"])
859
+ if "ide_cmd" in data:
860
+ # Blank clears the override and falls back to the stock command.
861
+ fields["ide_cmd"] = str(data["ide_cmd"] or "").strip()
815
862
  try:
816
863
  ok = _pm().update_project(path, **fields)
817
864
  return jsonify({"updated": ok})
@@ -3839,6 +3886,170 @@ def api_hub_access_rule_remove():
3839
3886
  return jsonify(result)
3840
3887
 
3841
3888
 
3889
+ # ── Builtin guard modes (v2.147.0) ─────────────────────────────────────────
3890
+
3891
+ def _hub_human():
3892
+ """The Desk client behind ``Authorization: Bearer``, else ``None``.
3893
+
3894
+ Loopback and the CSRF guard stop a browser, not a process on this
3895
+ machine: an agent can POST here as easily as a person. A Desk client
3896
+ token is minted only with ``bootstrap.key``, which Access Guard denies
3897
+ to agents, and is stored as a hash, so presenting one is the proof a
3898
+ loosening builtin change needs. A phone token does not count: builtin
3899
+ guards are not loosened from a device that leaves the building.
3900
+ """
3901
+ from oracle.services import client_tokens
3902
+ scheme, _, token = (request.headers.get("Authorization") or "").partition(" ")
3903
+ if scheme.lower() != "bearer" or not token.strip():
3904
+ return None
3905
+ who = client_tokens.principal_for(token.strip())
3906
+ return who if who and who.get("kind") == "desk" else None
3907
+
3908
+
3909
+ @app.route("/api/hub/access/builtin", methods=["GET"])
3910
+ def api_hub_access_builtin():
3911
+ """Every Tier-1 builtin with its live global mode, and each project's own.
3912
+
3913
+ Modes are read through the keyring (config and attestation must agree),
3914
+ so a mode listed here is one evaluation honours. ``projects`` holds only
3915
+ projects that set a mode of their own, or could not be read.
3916
+ ``strictness`` ranks each mode 0 (allow) to 3 (full deny), so a client
3917
+ can tell loosening from tightening without knowing the tiers.
3918
+ """
3919
+ try:
3920
+ global_modes = access_guard._scope_modes("global")
3921
+ except Exception as e:
3922
+ return jsonify({"error": str(e)}), 500
3923
+ home = access_guard._global_base()
3924
+ guards = []
3925
+ for glob in access_guard.DISABLEABLE_BUILTINS:
3926
+ canon = access_guard._norm_builtin(glob)
3927
+ guards.append({
3928
+ "glob": canon,
3929
+ "global": global_modes.get(canon, "default"),
3930
+ "strictness": {m: access_guard.builtin_strictness(canon, m)
3931
+ for m in (*access_guard.BUILTIN_MODES, "default")},
3932
+ })
3933
+ projects = []
3934
+ for p in _pm().list_registered():
3935
+ ppath = str(p.get("path") or "")
3936
+ row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
3937
+ "modes": {}, "error": None}
3938
+ try:
3939
+ base = Path(ppath).resolve()
3940
+ if (base / ".c3").is_dir() and base != home:
3941
+ row["modes"] = access_guard._scope_modes("project", ppath)
3942
+ except Exception as e:
3943
+ row["error"] = str(e)
3944
+ if row["modes"] or row["error"]:
3945
+ projects.append(row)
3946
+ from services import builtin_leases
3947
+ return jsonify({"guards": guards, "projects": projects,
3948
+ "leases": builtin_leases.active()})
3949
+
3950
+
3951
+ @app.route("/api/hub/access/builtin/mode", methods=["POST"])
3952
+ def api_hub_access_builtin_mode():
3953
+ """Set a Tier-1 builtin's mode in the global realm or one project's.
3954
+
3955
+ Body: ``{scope, path?, glob, mode, confirm?}``. A mode at least as strict
3956
+ as the live one needs nothing more. A looser one needs a Desk client
3957
+ token (403 ``needs_human`` without it) and ``confirm`` equal to the glob
3958
+ (400 ``needs_confirmation``). A project is judged against what its
3959
+ agents see today, which is the global mode unless it set its own.
3960
+ """
3961
+ data = request.get_json(silent=True) or {}
3962
+ scope, project, err = _hub_access_target(data)
3963
+ if err:
3964
+ return err
3965
+ glob = str(data.get("glob") or "")
3966
+ mode = str(data.get("mode") or "").strip()
3967
+ canon = access_guard._norm_builtin(glob)
3968
+ try:
3969
+ new = access_guard.builtin_strictness(canon, mode)
3970
+ live = (access_guard._scope_modes("global") if scope == "global"
3971
+ else access_guard.effective_builtin_modes(project))
3972
+ old = access_guard.builtin_strictness(canon, live.get(canon, "default"))
3973
+ except ValueError as exc:
3974
+ return jsonify({"error": str(exc)}), 400
3975
+ if new < old:
3976
+ if _hub_human() is None:
3977
+ return jsonify({
3978
+ "error": "loosening a built-in guard needs C3 Desk",
3979
+ "needs_human": True,
3980
+ "command": f'c3 access builtin mode "{canon}" {mode}'
3981
+ + (f' --project --path "{project}"'
3982
+ if scope == "project" else ""),
3983
+ }), 403
3984
+ if access_guard._norm_builtin(data.get("confirm")) != canon:
3985
+ return jsonify({
3986
+ "error": "loosening a built-in guard weakens protection",
3987
+ "needs_confirmation": True, "confirm_with": canon,
3988
+ }), 400
3989
+ try:
3990
+ result = access_guard.set_builtin_mode(canon, mode, scope,
3991
+ project or ".")
3992
+ except ValueError as exc:
3993
+ return jsonify({"error": str(exc)}), 400
3994
+ if result["changed"]:
3995
+ _hub_access_audit(f"builtin_mode_{result['mode']}", result["glob"],
3996
+ "builtin", scope, project)
3997
+ return jsonify({**result, "loosened": new < old})
3998
+
3999
+
4000
+ @app.route("/api/hub/access/builtin/lease", methods=["POST"])
4001
+ def api_hub_access_builtin_lease():
4002
+ """Loosen a builtin for a while: every session, or one.
4003
+
4004
+ Body: ``{scope, path?, glob, mode, ttl_s, session_id?, confirm}``. A
4005
+ lease only ever loosens, so it always needs a Desk client token and the
4006
+ glob typed back, and a lease no looser than the live mode is a 400
4007
+ rather than a row that would never apply. ``ttl_s`` is 60 s to 8 h.
4008
+ """
4009
+ from services import builtin_leases
4010
+ data = request.get_json(silent=True) or {}
4011
+ scope, project, err = _hub_access_target(data)
4012
+ if err:
4013
+ return err
4014
+ who = _hub_human()
4015
+ if who is None:
4016
+ return jsonify({"error": "a temporary built-in change needs C3 Desk",
4017
+ "needs_human": True}), 403
4018
+ canon = access_guard._norm_builtin(data.get("glob"))
4019
+ mode = str(data.get("mode") or "").strip()
4020
+ if access_guard._norm_builtin(data.get("confirm")) != canon or not canon:
4021
+ return jsonify({"error": "a temporary change weakens protection",
4022
+ "needs_confirmation": True, "confirm_with": canon}), 400
4023
+ try:
4024
+ live = access_guard.effective_builtin_modes(project or ".") \
4025
+ if scope == "project" else access_guard._scope_modes("global")
4026
+ if access_guard.builtin_strictness(canon, mode) >= \
4027
+ access_guard.builtin_strictness(canon, live.get(canon, "default")):
4028
+ return jsonify({"error": f"{canon} is already {live.get(canon, 'default')}"
4029
+ f" — a {mode} lease would change nothing"}), 400
4030
+ row = builtin_leases.mint(
4031
+ canon, mode, scope=scope, project_path=project or ".",
4032
+ session_id=str(data.get("session_id") or ""),
4033
+ ttl_s=data.get("ttl_s"), created_by=who["client_id"])
4034
+ except ValueError as exc:
4035
+ return jsonify({"error": str(exc)}), 400
4036
+ _hub_access_audit(f"builtin_lease_{mode}", canon, "builtin", scope, project)
4037
+ return jsonify({"lease": row})
4038
+
4039
+
4040
+ @app.route("/api/hub/access/builtin/lease/revoke", methods=["POST"])
4041
+ def api_hub_access_builtin_lease_revoke():
4042
+ """End a lease now. Tightening, so no token. Body: ``{id}``."""
4043
+ from services import builtin_leases
4044
+ lease_id = str((request.get_json(silent=True) or {}).get("id") or "")
4045
+ gone = builtin_leases.revoke(lease_id) if lease_id else None
4046
+ if gone is None:
4047
+ return jsonify({"error": "unknown or already-expired lease"}), 404
4048
+ _hub_access_audit("builtin_lease_revoke", gone["glob"], "builtin",
4049
+ gone["scope"], gone.get("project_path") or None)
4050
+ return jsonify({"revoked": lease_id})
4051
+
4052
+
3842
4053
  # ── Project management: tasks / milestones / notes (v2.45.0) ──────────────
3843
4054
  # Direct TaskStore per request (reload-per-op store — no runtime build needed
3844
4055
  # for mutations); every write audited to the target project's activity log.
@@ -19,8 +19,9 @@
19
19
  // hand — the server enforces that regardless of what this UI believes);
20
20
  // - a request that lapsed while the page showed it refreshes to its real
21
21
  // status (the decide route answers 409), never silently mints a grant;
22
- // - rule MUTATION stays on the per-project server and `c3 access` — this
23
- // tab approves and reads, it does not edit policy.
22
+ // - path-rule MUTATION stays on the per-project server and `c3 access`.
23
+ // Builtin guards may be TIGHTENED here; loosening one needs C3 Desk or
24
+ // the CLI, because this page holds no proof that a person clicked.
24
25
 
25
26
  const ACC_KIND_COLOR = (kind) => (
26
27
  kind === 'deny' ? T.error : kind === 'mask' ? T.blue
@@ -387,6 +388,121 @@ function AccRulesPanel({ projects, focus }) {
387
388
  );
388
389
  }
389
390
 
391
+ // ── Builtin guards ─────────────────────────────────────────────────────────
392
+ // The global mode of every Tier-1 builtin. Picking a stricter mode applies
393
+ // it; picking a looser one is refused by the server without a Desk token,
394
+ // and the panel shows the CLI command instead.
395
+ const ACC_BUILTIN_MODES = ['default', 'deny', 'confirm', 'allow'];
396
+
397
+ function AccBuiltinPanel() {
398
+ const [data, setData] = useState(null);
399
+ const [err, setErr] = useState('');
400
+ const [busy, setBusy] = useState('');
401
+ const [needs, setNeeds] = useState(null);
402
+
403
+ const load = useCallback(async () => {
404
+ try { setData(await api.get('/api/hub/access/builtin')); setErr(''); }
405
+ catch (e) { setErr(apiErr(e)); }
406
+ }, []);
407
+ useEffect(() => { load(); }, [load]);
408
+ usePoll(load, 15000);
409
+
410
+ const setMode = async (g, mode) => {
411
+ setBusy(g.glob); setNeeds(null);
412
+ try {
413
+ await api.post('/api/hub/access/builtin/mode', { scope: 'global', glob: g.glob, mode });
414
+ notify(`${g.glob} is now ${mode} everywhere`, 'ok');
415
+ } catch (e) {
416
+ const body = e && e.payload;
417
+ if (body && body.needs_human) setNeeds({ glob: g.glob, command: body.command });
418
+ else notify(apiErr(e), 'err');
419
+ }
420
+ setBusy('');
421
+ load();
422
+ };
423
+
424
+ const revoke = async (l) => {
425
+ setBusy(l.id);
426
+ try {
427
+ await api.post('/api/hub/access/builtin/lease/revoke', { id: l.id });
428
+ notify(`Ended the ${l.mode} lease on ${l.glob}`, 'ok');
429
+ } catch (e) { notify(apiErr(e), 'err'); }
430
+ setBusy('');
431
+ load();
432
+ };
433
+
434
+ const projects = (data && data.projects) || [];
435
+ const leases = (data && data.leases) || [];
436
+ return (
437
+ <div style={{
438
+ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 10,
439
+ padding: '12px 16px',
440
+ }}>
441
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
442
+ <div style={{ fontSize: 12.5, fontWeight: 700, color: T.text }}>Built-in guards</div>
443
+ <span style={{ fontSize: 10.5, color: T.textDim }}>
444
+ global mode — a stricter mode applies here; loosening one needs C3 Desk or the CLI
445
+ </span>
446
+ </div>
447
+ {err && <div style={{ marginTop: 8, fontSize: 11.5, color: T.error }}>{err}</div>}
448
+ {needs && (
449
+ <div style={{ marginTop: 8, fontSize: 11.5, color: T.warn }}>
450
+ Loosening <span className="mono">{needs.glob}</span> needs C3 Desk, or run:{' '}
451
+ <span className="mono" style={{ color: T.text }}>{needs.command}</span>
452
+ </div>
453
+ )}
454
+ {data && (
455
+ <div style={{ marginTop: 10, display: 'grid', gridTemplateColumns: 'minmax(0,1fr) auto', gap: '4px 12px', alignItems: 'center' }}>
456
+ {data.guards.map(g => (
457
+ <React.Fragment key={g.glob}>
458
+ <span className="mono" style={{ fontSize: 11.5, color: T.text, overflow: 'hidden', textOverflow: 'ellipsis' }}>{g.glob}</span>
459
+ <select value={g.global} disabled={busy === g.glob}
460
+ onChange={e => setMode(g, e.target.value)}
461
+ style={{
462
+ background: T.surfaceAlt, color: g.global === 'default' ? T.textMuted : T.warn,
463
+ border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 11.5, padding: '2px 6px',
464
+ }}>
465
+ {ACC_BUILTIN_MODES.map(m => <option key={m} value={m}>{m}</option>)}
466
+ </select>
467
+ </React.Fragment>
468
+ ))}
469
+ </div>
470
+ )}
471
+ {leases.length > 0 && (
472
+ <div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
473
+ <div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: 1, textTransform: 'uppercase', color: T.warn }}>
474
+ temporary changes
475
+ </div>
476
+ {leases.map(l => (
477
+ <div key={l.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11.5 }}>
478
+ <span className="mono" style={{ color: T.text }}>{l.glob}</span>
479
+ <span style={{ color: T.warn }}>{l.mode}</span>
480
+ <span style={{ color: T.textMuted }}>
481
+ {l.scope === 'project' ? l.project_path : 'all projects'}
482
+ {' · '}{l.session_id ? <>session <span className="mono">{l.session_id.slice(0, 8)}</span></> : 'all sessions'}
483
+ {' · '}ends in {accExpiresIn(l.expires_at) || '—'}
484
+ </span>
485
+ <div style={{ flex: 1 }} />
486
+ <Btn variant="ghost" disabled={busy === l.id} onClick={() => revoke(l)}>Revoke</Btn>
487
+ </div>
488
+ ))}
489
+ </div>
490
+ )}
491
+ {projects.length > 0 && (
492
+ <div style={{ marginTop: 8, fontSize: 11, color: T.textDim }}>
493
+ Set per project:{' '}
494
+ {projects.map(p => (
495
+ <span key={p.path} className="mono" style={{ marginRight: 10 }}>
496
+ {p.name}: {p.error ? `unreadable (${p.error})`
497
+ : Object.entries(p.modes).map(([g, m]) => `${g}=${m}`).join(', ')}
498
+ </span>
499
+ ))}
500
+ </div>
501
+ )}
502
+ </div>
503
+ );
504
+ }
505
+
390
506
  // ── Active grants ──────────────────────────────────────────────────────────
391
507
  // A rule grant can authorise many calls over hours. The only other place it
392
508
  // shows up is a line in the agent's own transcript, which is the wrong place
@@ -762,6 +878,7 @@ function HubAccess({ projects }) {
762
878
  )}
763
879
 
764
880
  <AccGrantsPanel projects={projects} />
881
+ <AccBuiltinPanel />
765
882
  <AccRulesPanel projects={projects} focus={ruleFocus} />
766
883
  {confirmSpec && (
767
884
  <CredConfirm spec={confirmSpec} onClose={() => setConfirmSpec(null)} />
@@ -528,24 +528,41 @@ function ReparentModal({ project, projects, onClose, onChanged }) {
528
528
 
529
529
  // ── IDE picker ─────────────────────────────────────────────────
530
530
  // POST /api/projects/launch-ide {path, ide, custom_cmd?}
531
+ // The command is editable per project (stored as ide_cmd via
532
+ // /api/projects/update) so an agent started through a wrapper — `yep` rather
533
+ // than `claude` — launches from the card too, not just from here.
531
534
  function IdePickerModal({ project, onClose, onChanged }) {
532
535
  const known = IDE_OPTIONS.some(o => o.id === (project && project.ide));
533
536
  const [selected, setSelected] = useState(known ? project.ide : 'claude-code');
534
- const [customCmd, setCustomCmd] = useState('');
537
+ const savedCmd = ((project && project.ide_cmd) || '').trim();
538
+ const [customCmd, setCustomCmd] = useState(savedCmd);
539
+ const [remember, setRemember] = useState(!!savedCmd);
535
540
  const [busy, setBusy] = useState(false);
536
541
 
542
+ const stockCmd = (id) => {
543
+ const opt = IDE_OPTIONS.find(o => o.id === id);
544
+ return opt && opt.cmd !== '...' ? opt.cmd : '';
545
+ };
546
+ const effectiveCmd = customCmd.trim() || stockCmd(selected);
547
+
537
548
  const launch = async () => {
538
- if (selected === 'custom' && !customCmd.trim()) {
549
+ const cmd = customCmd.trim();
550
+ if (selected === 'custom' && !cmd) {
539
551
  notify('Enter a custom command first.', 'err');
540
552
  return;
541
553
  }
542
554
  setBusy(true);
543
555
  try {
556
+ // Persist first: the project card launches with no command of its own.
557
+ const wanted = remember ? cmd : '';
558
+ if (wanted !== savedCmd) {
559
+ await api.post('/api/projects/update', { path: project.path, ide_cmd: wanted });
560
+ }
544
561
  await api.post('/api/projects/launch-ide', {
545
- path: project.path, ide: selected,
546
- custom_cmd: selected === 'custom' ? customCmd.trim() : '',
562
+ path: project.path, ide: selected, custom_cmd: cmd,
547
563
  });
548
- notify(`Launched ${ideLabel(selected)} in ${project.name || project.path}`);
564
+ notify(`Launched ${ideLabel(selected)} (${effectiveCmd}) in ${project.name || project.path}`);
565
+ if (onChanged) onChanged();
549
566
  onClose();
550
567
  } catch (e) {
551
568
  notify(`Launch failed: ${e.message}`, 'err');
@@ -575,13 +592,18 @@ function IdePickerModal({ project, onClose, onChanged }) {
575
592
  );
576
593
  })}
577
594
  </div>
578
- {selected === 'custom' && (
579
- <div>
580
- <MdlLabel>Custom command</MdlLabel>
581
- <input value={customCmd} onChange={e => setCustomCmd(e.target.value)}
582
- placeholder="e.g. nvim ." className="mono" style={mdlInputStyle()} />
583
- </div>
584
- )}
595
+ <div>
596
+ <MdlLabel>Command{selected === 'custom' ? '' : ' (leave blank for the default)'}</MdlLabel>
597
+ <input value={customCmd} onChange={e => setCustomCmd(e.target.value)}
598
+ placeholder={selected === 'custom' ? 'e.g. nvim .' : `${stockCmd(selected)} — override with e.g. yep`}
599
+ className="mono" style={mdlInputStyle()} />
600
+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6,
601
+ fontSize: 11, color: T.textMuted, cursor: 'pointer' }}>
602
+ <input type="checkbox" checked={remember}
603
+ onChange={e => setRemember(e.target.checked)} />
604
+ Remember for this project (used by the card's launch button too)
605
+ </label>
606
+ </div>
585
607
  <MdlFooter>
586
608
  <Btn variant="ghost" onClick={onClose}>Cancel</Btn>
587
609
  <Btn onClick={launch} disabled={busy}>
@@ -14,6 +14,7 @@ function SettingsModal({ onClose, onChanged }) {
14
14
  const [port, setPort] = useState('');
15
15
  const [autoBrowser, setAutoBrowser] = useState(true);
16
16
  const [oracleUrl, setOracleUrl] = useState('');
17
+ const [agentCmd, setAgentCmd] = useState(''); // ide_cmds['claude-code']
17
18
  const [cacheSize, setCacheSize] = useState(8);
18
19
  const [saving, setSaving] = useState(false);
19
20
  const [svc, setSvc] = useState(null); // hub: {installed, running, method, port, log_path} | {error}
@@ -51,6 +52,7 @@ function SettingsModal({ onClose, onChanged }) {
51
52
  setPort(d.port || 3330);
52
53
  setAutoBrowser(!!d.auto_open_browser);
53
54
  setOracleUrl(d.oracle_url || '');
55
+ setAgentCmd(((d.ide_cmds || {})['claude-code']) || '');
54
56
  setCacheSize(d.runtime_cache_size || 8);
55
57
  } catch {
56
58
  notify('Could not load hub config', 'err');
@@ -70,9 +72,14 @@ function SettingsModal({ onClose, onChanged }) {
70
72
  const cache = Math.max(1, parseInt(cacheSize, 10) || 8);
71
73
  setSaving(true);
72
74
  try {
75
+ // Blank restores the stock `claude`; other IDE overrides are left alone.
76
+ const ideCmds = { ...((cfg && cfg.ide_cmds) || {}) };
77
+ if (agentCmd.trim()) ideCmds['claude-code'] = agentCmd.trim();
78
+ else delete ideCmds['claude-code'];
73
79
  await api.post('/api/hub/config', {
74
80
  port: portVal, auto_open_browser: autoBrowser,
75
81
  oracle_url: oracleUrl.trim(), runtime_cache_size: cache,
82
+ ide_cmds: ideCmds,
76
83
  });
77
84
  const portChanged = cfg && portVal !== cfg.port;
78
85
  notify(portChanged
@@ -222,6 +229,10 @@ function SettingsModal({ onClose, onChanged }) {
222
229
  <input type="number" value={cacheSize} onChange={e => setCacheSize(e.target.value)}
223
230
  min={1} className="mono" style={inputStyle(110)} />
224
231
  ), 'Max project runtimes kept warm for drill-in and global search.')}
232
+ {fieldRow('Agent command', (
233
+ <input value={agentCmd} onChange={e => setAgentCmd(e.target.value)}
234
+ placeholder="claude" className="mono" style={inputStyle(220)} />
235
+ ), 'Default command for "Claude Code CLI" launches — e.g. yep for a wrapper script. A project can override it in Open in IDE.')}
225
236
  {fieldRow('Oracle URL', (
226
237
  <input value={oracleUrl} onChange={e => setOracleUrl(e.target.value)}
227
238
  placeholder="http://localhost:3331" className="mono" style={inputStyle(220)} />
cli/mcp_server.py CHANGED
@@ -23,9 +23,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
23
23
 
24
24
  from fastmcp import Context, FastMCP
25
25
 
26
+ from cli.tools import _grants
26
27
  from core.host import resolve_host
27
28
  from core.ide import get_profile
28
- from services import delegate_hints
29
+ from services import access_guard, delegate_hints
29
30
  from services.auto_memory import AutoMemory
30
31
  from services.context_snapshot import ContextSnapshot
31
32
  from services.runtime import C3Runtime, build_runtime, start_runtime, stop_runtime
@@ -191,6 +192,14 @@ def _notify_mcp_ready(svc) -> None:
191
192
  async def lifespan(server):
192
193
  """Initialize all services, auto-start session, start file watcher."""
193
194
  project = PROJECT_PATH
195
+ # numpy's native module must load before any background thread exists: on
196
+ # Windows, loading it from c3-initial-index (via chromadb) while this
197
+ # process starts other threads deadlocks both, and the first tool call
198
+ # never answers. Absent numpy, nothing here needs it.
199
+ try:
200
+ import numpy # noqa: F401, PLC0415
201
+ except ImportError:
202
+ pass
194
203
  services = build_runtime(project, ide_name=_IDE_NAME)
195
204
  if getattr(services, "time_tracker", None) is not None:
196
205
  try:
@@ -508,6 +517,7 @@ def _ensure_repo_map_once(rt) -> None:
508
517
  def _svc(ctx: Context) -> C3Runtime:
509
518
  rt = ctx.request_context.lifespan_context
510
519
  _ensure_repo_map_once(rt)
520
+ access_guard.bind_session(_grants.session_id(rt))
511
521
  return rt
512
522
 
513
523
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.146.0
3
+ Version: 2.149.1
4
4
  Summary: 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.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,10 +1,10 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=RhfS2WS5VMSi2w2Jxcts1wg5WC-UFp89JOQvcO5Jjb8,23002
3
3
  cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
4
- cli/c3.py,sha256=AashzO8-7Jfq2V61RXFh2z5KfF5Oux5INUY-2s6IQVk,431810
4
+ cli/c3.py,sha256=P-UUPaigu00WNQgtYuyOrpG9UQGfhKQl1uicUsT6Qgk,431810
5
5
  cli/docs.html,sha256=pPxcP5ieY0DcfEDvegwIcRxbz1woLZoKLVSF-4JVy0Q,146784
6
6
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
7
- cli/hook_access_guard.py,sha256=Mjrrt3agYCWPqSjdzk_Sw4ooMrizO8Um-nQjgGfXck4,27896
7
+ cli/hook_access_guard.py,sha256=tzK3pQBGehDDZi6fECcYk8nRhy0QkmUtTF2TuJG7nbY,27928
8
8
  cli/hook_agent_model.py,sha256=NzetPAuzl7eiWgr6tGzWUY24qiT717FD-QqeFOudGrg,2784
9
9
  cli/hook_artifact.py,sha256=Se1CNBfoBFyvJQlRmYdNtdRXIkQp9zaF0O6ndRMo7ts,2198
10
10
  cli/hook_auto_snapshot.py,sha256=yGFqRANtw8qNqRNr-oCgpuyaUyTkSzIOFxXt5kauq4I,5271
@@ -23,10 +23,10 @@ cli/hook_session_end.py,sha256=toFEPDKRoutDuVOmuOgHmQ8CM0o7tF7yALhMxJz7VEM,3482
23
23
  cli/hook_session_open.py,sha256=Badeikv37D7b6inSHbVjAxtfdH4xiJKFT1O52fgreGs,7443
24
24
  cli/hook_session_stats.py,sha256=I-2NeLlV1NxeqKtTM2JH9haSpFU7r4qG0u8pfCJ80YU,8725
25
25
  cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,6252
26
- cli/hub_server.py,sha256=of3Auy9HFPSa1gDqrRITOfv29wz6uqi62Afb0iFW7uE,218145
26
+ cli/hub_server.py,sha256=VgDAlVmWgvh57KUbMgL2PT4303fmlr578QLk6ehe2pA,227863
27
27
  cli/hub_ui.html,sha256=v41R_JAH4Jwodpy3tbeBUel-80FbNmffBrey-QH5Q64,2963
28
28
  cli/mcp_proxy.py,sha256=r-Qhlie7gCtotex8yQUudCyBLRUK-IW6cNIW5RhBW0o,17648
29
- cli/mcp_server.py,sha256=5y_oaJvvGq6p1C4L-4MamudyAhpJYeIjDJcXSeB9dsM,73775
29
+ cli/mcp_server.py,sha256=Omsjg01JePjMrbJwXBMAmJ6zX0MNjTbREJ6BhfuVlI4,74248
30
30
  cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
31
31
  cli/server.py,sha256=dJOOXYU1zmN-UBuMmHhv8XlnknSyO8xj7mXWSDAGIBY,173188
32
32
  cli/ui.html,sha256=uuPwIJNKDQH_fsaR3xNB2AKky9WzKfaXnKr_k3aTtxo,5715
@@ -60,7 +60,7 @@ cli/hub_ui/components/drill_subprojects.js,sha256=wdPNTIMrrLLQI5Qxi3xk-rZFMDO8pF
60
60
  cli/hub_ui/components/drill_tasks.js,sha256=1iG8Kx9vCoCIHFDXCVtGiw_HwYff3SuScM90tj0cFRQ,14245
61
61
  cli/hub_ui/components/drill_views.js,sha256=HyYPcpl9VXAcrmYRNEodHl9cNcFJMrF8XwTvCR3vdsc,15480
62
62
  cli/hub_ui/components/global_search.js,sha256=GPfq1EsZa73vXBNCVn4VhT3a7gYkaUblbcXOFCe7PJQ,11395
63
- cli/hub_ui/components/hub_access.js,sha256=AF00OgOGak4ASPE2OvxOsBqjS_KKd5QKCZZ6Kp3zGXw,34339
63
+ cli/hub_ui/components/hub_access.js,sha256=dllDqBIGdUWTJVxmdfv2OYM_d2f-K1TShqtPqH_dju0,39551
64
64
  cli/hub_ui/components/hub_ci.js,sha256=KrdzALpgFTFcPEMN1R-OU0tyl8irsj91HECRlGsQLjQ,17873
65
65
  cli/hub_ui/components/hub_cred_audit.js,sha256=fUmAPTOa75gdxAsw6cNHPYenu7jDTljSbFX9GYJnW14,11662
66
66
  cli/hub_ui/components/hub_credentials.js,sha256=xDqxDb0a0yaJQgTE-hEtx0PTOo4x9j__5GhdlxGd9mo,119643
@@ -69,11 +69,11 @@ cli/hub_ui/components/hub_locks.js,sha256=v1yv7JH5anfC_kZo26I1Dyt1_ytamGOaBpCIWG
69
69
  cli/hub_ui/components/hub_sessions.js,sha256=Uo-U3T9ZIqSgiBkfNIWB3K2c-J-WBJApUU2IPSi5BZk,21401
70
70
  cli/hub_ui/components/hub_tokens.js,sha256=oBFTvW5zJShpBaqIqmJPK_g7SpEiulurMRjlfO34T2c,18565
71
71
  cli/hub_ui/components/mcp_manager.js,sha256=Bqd74xuHVyADpILJZbFHTlM2gcUpG1nH_ngZhMlobaU,17405
72
- cli/hub_ui/components/modals.js,sha256=jt5om8xvUJjieftfgWoWHGVGKxkt14FArYS-WLazwA0,65731
72
+ cli/hub_ui/components/modals.js,sha256=-vV55ENULEeovjDcse-wvIotbDxJKlFrUf2sv_crqok,66991
73
73
  cli/hub_ui/components/project_card.js,sha256=V_g5LUUj99IPdjw5f98ViZ-mbBGTXt_RKqq_0g-tPMs,30722
74
74
  cli/hub_ui/components/project_tree.js,sha256=jPgkHZwgBM9iNT4pxXPTMgV4dtZpOX5EQsGvzcL4PP0,3829
75
75
  cli/hub_ui/components/session_drawer.js,sha256=OEBvdF3PqRji9CEntolDMVplXF2NTG2xwmXCqkcb0Nw,7553
76
- cli/hub_ui/components/settings_modal.js,sha256=r7JGRlnYEA4l1L7rSfCpXbeKg5DxNFFNj9aT5QuP4bI,13761
76
+ cli/hub_ui/components/settings_modal.js,sha256=gz2G8gpA5yZ4q0KS3GKlDqrJxvSx_kkbLXYxpGm8g0g,14503
77
77
  cli/hub_ui/components/sidebar.js,sha256=RH0OlqCINFNCIXgns-wgge5gG8vkn5wyfRT87qdmo1I,4585
78
78
  cli/hub_ui/components/summary_bar.js,sha256=JnMq01Oum8DwwUOhFnX5mr8K7I-ERdPlbaBOR8QT72o,2993
79
79
  cli/hub_ui/components/task_board.js,sha256=QiWKmj1NkhkloGlfzZj_tWd7Sgz3xSxwVLp8I5OfIiM,15952
@@ -131,7 +131,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
131
131
  cli/ui/components/settings.js,sha256=FXcn-UQE2mXC6HFsLeEas-wAUFKt0OJaLySroZQMhGA,84095
132
132
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
133
133
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
134
- code_context_control-2.146.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
134
+ code_context_control-2.149.1.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
135
135
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
136
136
  core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
137
137
  core/config.py,sha256=wCjrZKXRywUbR6WRcO7optyOirAQ2DeRNdPCU0X5zIY,23762
@@ -188,7 +188,7 @@ oracle/ui/chat/send.js,sha256=X_Uc6EuuIxkUTlRWGGYOq6gQkJCQeCE3p5NMyxx7RzQ,11911
188
188
  oracle/ui/chat/stream_renderer.js,sha256=RZndI2mu5xbzZF-4NG9jeOqXTcmIqfZqxgkrBvTzWmk,29861
189
189
  oracle/ui/chat/toolbar.js,sha256=ZDexIIpGwsQ6XsY4Tyo082rJoofdD_JBTV97-mLQUVM,12027
190
190
  services/__init__.py,sha256=3Kn4cZweLm7at8wFdBdZ-Zwo8hHcnVIsmY5f29nzi2Y,116
191
- services/access_guard.py,sha256=tj-rWVlEzLIviIEXMRAYCWRlcgLPUwJ1qqQfmIA5t7Q,79074
191
+ services/access_guard.py,sha256=Qr9jl3DPINytvgXzuZJtnA2yyXq0GnQguGQwXinK7XE,81458
192
192
  services/access_telemetry.py,sha256=9pOCLS9LgDtM7Z4cCEyl7osxhj-m3wsPubcXolBKuV4,9568
193
193
  services/activity_log.py,sha256=c_jTOsR-d1d5B3u67eg8mRA7LZhtP6H1-6leeGa0Go4,7990
194
194
  services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
@@ -204,6 +204,7 @@ services/background_service.py,sha256=Hn-vjAUq_wPm0cUiwwzyr-D7Xxkygjuy7s3_9XuLaO
204
204
  services/benchmark_dashboard.py,sha256=iR-DnqnoKbqHMJ4d-ZkIvJBYfzwTa7r-jzO6j2BYDfQ,27711
205
205
  services/bitbucket_client.py,sha256=2RehoyVP4vc52zjQi3Gdlt2WRAUpFv8zVMn12xx1yHE,22338
206
206
  services/bitbucket_credentials.py,sha256=2qLA9pQMol4y95y4DJMNBsBBPUsJQCKbLFo2iiCnfvI,7364
207
+ services/builtin_leases.py,sha256=WRYBQRMckz2GZ80Zw2QtgY0WwnbxF1ULDrFp5B07qlk,6298
207
208
  services/ci_act.py,sha256=82i5aa1wgQvc8Xq_pQ9-k1_57LBQ0-mWSUT9xV-q7Tk,24070
208
209
  services/ci_cache.py,sha256=Too3XyeE-oeiqQm4IJ10giFvhIEr29YfDLUpZDl8SBU,13256
209
210
  services/ci_expr.py,sha256=BsCUoL4ZN1Q8KjYJ07xWsPynV04Q8dg-ryIIKNOYk70,15641
@@ -274,7 +275,7 @@ services/override_policy.py,sha256=TehVPxfQ8LZWIOUzCnnioIdMUPZZ7JZa1ja77smpW2c,3
274
275
  services/override_requests.py,sha256=H9lSUbfOjkjE1muztaQ58InqqsqhtnKIT-pF2VzvWkg,31148
275
276
  services/override_wake.py,sha256=HjSOXQTDi0irgeMyrTqngL7xLcSOOIgYsVW9yxod4ps,10363
276
277
  services/parser.py,sha256=S_YoKLS3r1xCUVHApXb3BTEfoRAzFhuo1_8XsG0qxVE,63505
277
- services/project_manager.py,sha256=ibgNOqmNQBjdr_dXkbcsUdsnOriroYKW87wMnGZCovk,48376
278
+ services/project_manager.py,sha256=7NvQau5x0PCVagQsXpuXZ4VtXCkCfy9es_v1oEHmzgs,48718
278
279
  services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
279
280
  services/protocol.py,sha256=CWZ0BWWd-jPui-11hZlvGgkNk18gMFhhLWbp-BP35Kk,12861
280
281
  services/proxy_state.py,sha256=u5rd0k6CrOsywZA8FpRu_hMLwhR0TAJhZjy5MdWbCGc,6107
@@ -333,8 +334,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
333
334
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
334
335
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
335
336
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
336
- code_context_control-2.146.0.dist-info/METADATA,sha256=qMaA5mgjHH8SPwuUFvut1k-KVaWK3DuHawvK8axeKNs,28902
337
- code_context_control-2.146.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
338
- code_context_control-2.146.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
339
- code_context_control-2.146.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
340
- code_context_control-2.146.0.dist-info/RECORD,,
337
+ code_context_control-2.149.1.dist-info/METADATA,sha256=OPwBuyGX5nRTAGhNLG_qcwpq9LFLYT_k2CsnN46Jk3A,28902
338
+ code_context_control-2.149.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
339
+ code_context_control-2.149.1.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
340
+ code_context_control-2.149.1.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
341
+ code_context_control-2.149.1.dist-info/RECORD,,
services/access_guard.py CHANGED
@@ -60,6 +60,14 @@ BUILTIN_MODES = ("deny", "confirm", "allow")
60
60
  # (or, worse, silently disable one).
61
61
  _ACCESS_KEYRING_SERVICE = "c3-access"
62
62
 
63
+ # Temporary and per-session builtin modes (services/builtin_leases.py), in
64
+ # ~/.c3. Named here so the hot path can skip the lease module when no file
65
+ # exists.
66
+ LEASES_FILE = "builtin_leases.json"
67
+
68
+ # The agent session this process evaluates for; see bind_session().
69
+ _SESSION_ID = ""
70
+
63
71
  # Mask presets (docs/mask-guard.md §4). Names + param schema live here so the
64
72
  # hook subprocess can validate config without importing the transform engines;
65
73
  # the engines themselves are in services/mask_presets.py.
@@ -600,10 +608,33 @@ def effective_builtin_modes(project_path: str = ".") -> dict:
600
608
  """
601
609
  out = _scope_modes("global")
602
610
  proj_base = _scope_base("project", project_path)
603
- if proj_base is None or proj_base == _global_base():
604
- return out # the home directory IS the project; one scope, not two
605
- out.update(_scope_modes("project", project_path))
606
- return out
611
+ # When the home directory IS the project there is one scope, not two.
612
+ if proj_base is not None and proj_base != _global_base():
613
+ out.update(_scope_modes("project", project_path))
614
+ return _lease_overlay(out, project_path)
615
+
616
+
617
+ def bind_session(session_id: str) -> None:
618
+ """Name the agent session this process evaluates for, so leases bound to
619
+ that session apply. The hook binds per call from its payload; the MCP
620
+ server binds before each tool call. Unbound, only all-session leases
621
+ apply."""
622
+ global _SESSION_ID
623
+ _SESSION_ID = str(session_id or "")
624
+
625
+
626
+ def _lease_overlay(modes: dict, project_path: str) -> dict:
627
+ """*modes* with live leases applied. A lease only loosens: one that is
628
+ stricter than the standing mode is ignored."""
629
+ home = _global_base()
630
+ if home is None or not (home / ".c3" / LEASES_FILE).is_file():
631
+ return modes
632
+ from services import builtin_leases # noqa: PLC0415 — only when leases exist
633
+ for glob, mode in builtin_leases.live_modes(project_path, _SESSION_ID).items():
634
+ if builtin_strictness(glob, mode) < builtin_strictness(
635
+ glob, modes.get(glob, "default")):
636
+ modes[glob] = mode
637
+ return modes
607
638
 
608
639
 
609
640
  def builtin_mode_realms(project_path: str = ".") -> dict:
@@ -621,6 +652,31 @@ def builtin_mode_realms(project_path: str = ".") -> dict:
621
652
  return out
622
653
 
623
654
 
655
+ _KIND_RANK = {_KIND_CONFIRM: 1, _KIND_READ_ONLY: 2, _KIND_DENY: 3}
656
+
657
+
658
+ def builtin_strictness(glob, mode: str) -> int:
659
+ """How strict *mode* makes a Tier-1 builtin: 0 (allow) to 3 (full deny).
660
+
661
+ The same mode word means different rules per tier — ``default`` is a full
662
+ deny on ``**/.env*`` and read-only on ``**/.git/**`` — so loosening is
663
+ only comparable through this. Raises ValueError for a glob that is not
664
+ Tier 1 or an unknown mode.
665
+ """
666
+ canon = _norm_builtin(glob)
667
+ variants = _BUILTIN_VARIANTS.get(canon)
668
+ if variants is None:
669
+ raise ValueError(f"'{_norm_glob(glob)}' is not a disableable builtin "
670
+ f"— expected one of: {', '.join(DISABLEABLE_BUILTINS)}")
671
+ if mode not in BUILTIN_MODES + ("default",):
672
+ raise ValueError(f"unknown mode '{mode}' — expected one of: "
673
+ f"{', '.join(BUILTIN_MODES + ('default',))}")
674
+ if mode == "allow":
675
+ return 0
676
+ return _KIND_RANK[variants[mode if mode in ("deny", "confirm")
677
+ else "default"].kind]
678
+
679
+
624
680
  def disabled_builtins(project_path: str = ".") -> frozenset:
625
681
  """Canonical globs of builtins that are genuinely OFF right now — the
626
682
  legacy two-key opt-out plus mode="allow" entries. Callers that only ask
@@ -0,0 +1,182 @@
1
+ """Temporary and per-session modes for Tier-1 builtin guards.
2
+
3
+ A lease loosens one builtin to ``confirm`` or ``allow`` until it expires, for
4
+ every session (``session_id`` empty) or for one agent session. It never
5
+ touches the permanent two-key mode: when a lease lapses the guard is back
6
+ where it was, with nothing to undo.
7
+
8
+ Leases are two-key like modes. A row in ``~/.c3/builtin_leases.json`` counts
9
+ only while the keyring (service ``c3-access``, account ``lease|<id>``) holds
10
+ the sha256 of that exact row, so a row written, copied or extended by hand
11
+ has no matching attestation and is ignored. Every failure path ignores the
12
+ lease, which leaves the guard on.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import secrets
19
+ from datetime import datetime, timedelta, timezone
20
+
21
+ from services import access_guard as ag
22
+ from services.atomic_json import write_json_atomic
23
+
24
+ LEASE_MODES = ("confirm", "allow")
25
+ MIN_TTL_S = 60
26
+ MAX_TTL_S = 8 * 3600
27
+ _LOCK_FILE = ".c3/builtin_leases.lock"
28
+
29
+
30
+ def _now() -> datetime:
31
+ return datetime.now(timezone.utc)
32
+
33
+
34
+ def _parse(value) -> datetime | None:
35
+ try:
36
+ dt = datetime.fromisoformat(str(value))
37
+ except ValueError:
38
+ return None
39
+ return dt if dt.tzinfo else None
40
+
41
+
42
+ def _store():
43
+ base = ag._global_base()
44
+ return base / ".c3" / ag.LEASES_FILE if base is not None else None
45
+
46
+
47
+ def _account(lease_id: str) -> str:
48
+ return f"lease|{lease_id}"
49
+
50
+
51
+ def _digest(row: dict) -> str:
52
+ blob = json.dumps(row, sort_keys=True, separators=(",", ":"))
53
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
54
+
55
+
56
+ def _attested(row: dict) -> bool:
57
+ try:
58
+ import keyring # noqa: PLC0415 — lazy, as in access_guard
59
+ return keyring.get_password(ag._ACCESS_KEYRING_SERVICE,
60
+ _account(str(row.get("id")))) == _digest(row)
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ def _load() -> list:
66
+ path = _store()
67
+ if path is None or not path.is_file():
68
+ return []
69
+ try:
70
+ rows = json.loads(path.read_text(encoding="utf-8")).get("leases")
71
+ except (OSError, ValueError, AttributeError):
72
+ return []
73
+ return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
74
+
75
+
76
+ def _live(row: dict, at: datetime) -> bool:
77
+ exp = _parse(row.get("expires_at"))
78
+ return exp is not None and at < exp
79
+
80
+
81
+ def active() -> list:
82
+ """Live, attested leases, soonest to expire first."""
83
+ at = _now()
84
+ return sorted((r for r in _load() if _live(r, at) and _attested(r)),
85
+ key=lambda r: str(r.get("expires_at")))
86
+
87
+
88
+ def live_modes(project_path: str, session_id: str) -> dict:
89
+ """``{canonical_glob: mode}`` from the leases that reach this project and
90
+ session. ``allow`` wins over ``confirm`` when two leases cover one glob."""
91
+ rows = _load()
92
+ if not rows:
93
+ return {}
94
+ realms = {"global", ag.realm("project", project_path)}
95
+ at = _now()
96
+ out = {}
97
+ for r in rows:
98
+ if (r.get("realm") in realms and r.get("mode") in LEASE_MODES
99
+ and r.get("session_id", "") in ("", session_id)
100
+ and _live(r, at) and _attested(r)):
101
+ if out.get(r["glob"]) != "allow":
102
+ out[r["glob"]] = r["mode"]
103
+ return out
104
+
105
+
106
+ def _save(rows: list) -> None:
107
+ write_json_atomic(_store(), {"leases": rows})
108
+
109
+
110
+ def _lock():
111
+ from services.override_grants import _Lock # noqa: PLC0415 — off the hot path
112
+ return _Lock(ag._global_base(), _LOCK_FILE)
113
+
114
+
115
+ def mint(glob, mode: str, *, scope: str, project_path: str = ".",
116
+ session_id: str = "", ttl_s: int, created_by: str) -> dict:
117
+ """Create a lease and return its row.
118
+
119
+ Raises ValueError for a glob that is not Tier 1, a mode other than
120
+ confirm/allow, a TTL outside 60 s–8 h, a scope without a realm, or a
121
+ keyring that will not attest (attestation is written first, so a lease
122
+ that could never count is never stored).
123
+ """
124
+ if mode not in LEASE_MODES:
125
+ raise ValueError(f"a lease mode must be one of: {', '.join(LEASE_MODES)}")
126
+ ag.builtin_strictness(glob, mode)
127
+ try:
128
+ ttl = int(ttl_s)
129
+ except (TypeError, ValueError):
130
+ raise ValueError("ttl_s must be a whole number of seconds") from None
131
+ if not MIN_TTL_S <= ttl <= MAX_TTL_S:
132
+ raise ValueError(f"ttl_s must be between {MIN_TTL_S} and {MAX_TTL_S}")
133
+ if scope not in ("global", "project"):
134
+ raise ValueError("scope must be 'global' or 'project'")
135
+ realm = ag.realm(scope, project_path)
136
+ if not realm or _store() is None:
137
+ raise ValueError("no home directory to hold the lease")
138
+ at = _now()
139
+ row = {
140
+ "id": "bls_" + secrets.token_hex(6),
141
+ "glob": ag._norm_builtin(glob),
142
+ "mode": mode,
143
+ "scope": scope,
144
+ "realm": realm,
145
+ "project_path": str(project_path) if scope == "project" else "",
146
+ "session_id": str(session_id or ""),
147
+ "created_at": at.isoformat(),
148
+ "expires_at": (at + timedelta(seconds=ttl)).isoformat(),
149
+ "created_by": str(created_by),
150
+ }
151
+ try:
152
+ import keyring # noqa: PLC0415
153
+ keyring.set_password(ag._ACCESS_KEYRING_SERVICE, _account(row["id"]),
154
+ _digest(row))
155
+ except Exception:
156
+ raise ValueError("keyring unavailable, so the lease cannot be attested "
157
+ "and would not take effect") from None
158
+ with _lock():
159
+ rows = [r for r in _load() if _live(r, at)]
160
+ rows.append(row)
161
+ _save(rows)
162
+ return row
163
+
164
+
165
+ def revoke(lease_id: str) -> dict | None:
166
+ """Remove a lease; returns the removed row, or None if it was not live.
167
+ Expired rows are swept on the way."""
168
+ if _store() is None:
169
+ return None
170
+ at = _now()
171
+ with _lock():
172
+ rows = _load()
173
+ gone = next((r for r in rows if r.get("id") == lease_id), None)
174
+ _save([r for r in rows if r is not gone and _live(r, at)])
175
+ if gone is None:
176
+ return None
177
+ try:
178
+ import keyring # noqa: PLC0415
179
+ keyring.set_password(ag._ACCESS_KEYRING_SERVICE, _account(lease_id), "")
180
+ except Exception: # a keyring that cannot be written cannot be read either
181
+ pass # so a re-added copy of the row stays unattested
182
+ return gone if _live(gone, at) else None
@@ -794,11 +794,19 @@ class ProjectManager:
794
794
  "initialized": bool(cfg),
795
795
  }
796
796
 
797
+ def get_ide_cmd(self, path: str) -> str:
798
+ """Per-project launch command override (e.g. `yep` instead of `claude`)."""
799
+ path = str(Path(path).resolve())
800
+ for p in self._read_projects():
801
+ if p["path"] == path:
802
+ return str(p.get("ide_cmd") or "").strip()
803
+ return ""
804
+
797
805
  def update_project(self, path: str, **fields) -> bool:
798
- """Update editable project fields: name, tags, notes."""
806
+ """Update editable project fields: name, tags, notes, ide_cmd."""
799
807
  path = str(Path(path).resolve())
800
808
  projects = self._read_projects()
801
- allowed = {"name", "tags", "notes", "autostart_ui"}
809
+ allowed = {"name", "tags", "notes", "autostart_ui", "ide_cmd"}
802
810
  for p in projects:
803
811
  if p["path"] == path:
804
812
  for k, v in fields.items():