code-context-control 2.124.0__py3-none-any.whl → 2.126.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/_hook_utils.py CHANGED
@@ -500,6 +500,40 @@ def record_json_unlocks(
500
500
  pass
501
501
 
502
502
 
503
+ _PROJECT_WALK_UP_LEVELS = 4
504
+
505
+
506
+ def find_project(payload: dict, project_path=None) -> Path | None:
507
+ """The nearest ancestor (≤ 4 levels) of ``project_path`` / ``payload.cwd``
508
+ that owns a ``.c3`` directory, or None.
509
+
510
+ Claude Code runs hooks from the project root, Codex passes ``--project``;
511
+ either way the payload's ``cwd`` may be a subdirectory (a session opened
512
+ in ``src/``). The walk is lexical on purpose — no path canonicalisation
513
+ happens here (see tests/test_access_guard_meta.py).
514
+ """
515
+ candidates = []
516
+ if project_path:
517
+ candidates.append(Path(str(project_path)))
518
+ cwd = payload.get("cwd") if isinstance(payload, dict) else None
519
+ if cwd:
520
+ candidates.append(Path(str(cwd)))
521
+ if not candidates:
522
+ candidates.append(Path.cwd())
523
+ for base in candidates:
524
+ node = base
525
+ for _ in range(_PROJECT_WALK_UP_LEVELS):
526
+ try:
527
+ if (node / ".c3").is_dir():
528
+ return node
529
+ except OSError:
530
+ break
531
+ if node.parent == node:
532
+ break
533
+ node = node.parent
534
+ return None
535
+
536
+
503
537
  def emit_additional_context(text: str, is_gemini: bool) -> None:
504
538
  """Write additionalContext JSON to stdout in the correct format for the IDE."""
505
539
  if is_gemini:
cli/c3.py CHANGED
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
92
92
  # Config
93
93
  CONFIG_DIR = ".c3"
94
94
  CONFIG_FILE = ".c3/config.json"
95
- __version__ = "2.124.0"
95
+ __version__ = "2.126.1"
96
96
 
97
97
 
98
98
  def _compress_file_cli(compressor, path, mode="smart", **kw):
@@ -5558,6 +5558,8 @@ def cmd_install_mcp(args):
5558
5558
  hook_posttool_cmd = f"{_dispatch_base} posttool"
5559
5559
  hook_stop_cmd = f"{_dispatch_base} stop"
5560
5560
  hook_prompt_cmd = f"{_dispatch_base} prompt"
5561
+ hook_start_cmd = f"{_dispatch_base} start"
5562
+ hook_end_cmd = f"{_dispatch_base} end"
5561
5563
 
5562
5564
  shell_matcher = "Bash"
5563
5565
  read_matcher = "Read"
@@ -5696,6 +5698,25 @@ def cmd_install_mcp(args):
5696
5698
  )
5697
5699
  settings.setdefault("hooks", {})[prompt_event] = existing_prompt
5698
5700
 
5701
+ # ── SessionStart / SessionEnd hooks (v2.126.0: session_open /
5702
+ # session_end activity rows + `session` notifications, the join key
5703
+ # a desktop client needs). Same merge discipline: replace only C3's
5704
+ # own dispatcher entries, keep user-added ones. SessionStart's
5705
+ # matcher is the payload `source` (startup|resume|clear|compact);
5706
+ # empty matches all — the hook itself ignores `compact`.
5707
+ for _lifecycle_event, _lifecycle_cmd in (
5708
+ ("SessionStart", hook_start_cmd),
5709
+ ("SessionEnd", hook_end_cmd),
5710
+ ):
5711
+ _kept = [
5712
+ h for h in settings.get("hooks", {}).get(_lifecycle_event, [])
5713
+ if not _is_c3_prompt_hook(h)
5714
+ ]
5715
+ _kept.append(
5716
+ {"matcher": "", "hooks": [{"type": "command", "command": _lifecycle_cmd}]}
5717
+ )
5718
+ settings.setdefault("hooks", {})[_lifecycle_event] = _kept
5719
+
5699
5720
  # Claude Code only: enable MCP server prompt settings
5700
5721
  if profile.name == "claude-code":
5701
5722
  settings["enableAllProjectMcpServers"] = True
@@ -5742,6 +5763,7 @@ def cmd_install_mcp(args):
5742
5763
  print(f" Hooks ({hook_event}): dispatcher (1 spawn/event) — filter/ghost/read-guard/ledger/unlock/signal via cli/hook_dispatch.py posttool")
5743
5764
  print(f" Hooks ({pre_event}): dispatcher — {read_matcher}/{grep_matcher}/{glob_matcher}/{edit_matcher}/{write_matcher} (c3 enforcement)")
5744
5765
  print(" Hooks (Stop): dispatcher — session_stats + auto_snapshot + terse_advisor")
5766
+ print(" Hooks (SessionStart/SessionEnd): dispatcher — session_open / session_end rows + session notifications")
5745
5767
  if profile.name == "claude-code":
5746
5768
  print(" Claude MCP prompt settings enabled for this project")
5747
5769
  if perm_tier and profile.name == "claude-code":
cli/hook_dispatch.py CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Per-event hook dispatcher: ONE process per Claude Code / Gemini hook event.
3
3
 
4
- Usage: python hook_dispatch.py <pretool|posttool|stop|prompt>
4
+ Usage: python hook_dispatch.py <pretool|posttool|stop|prompt|start|compact|end>
5
5
 
6
6
  Before v2.42 every hook was registered as its own "cmd /c python <hook>.py"
7
7
  subprocess, so a single native Read fired up to three interpreter spawns
@@ -167,8 +167,17 @@ def _routes(event: str, raw_tool: str, norm_tool: str, host: str = HOST_CLAUDE):
167
167
  yield "hook_terse_advisor"
168
168
  elif event == "prompt":
169
169
  yield "hook_prompt_recall"
170
- elif event in ("start", "compact", "end") and host == HOST_CODEX:
171
- yield "hook_codex_lifecycle"
170
+ elif event in ("start", "compact", "end"):
171
+ # Codex's thread-bound handoff runs first (it may return context on
172
+ # SessionStart); the session bookkeeping below is host-agnostic and
173
+ # returns nothing (v2.126.0: session_open / session_end rows + the
174
+ # `session` notifications a desktop client wakes on).
175
+ if host == HOST_CODEX:
176
+ yield "hook_codex_lifecycle"
177
+ if event == "start":
178
+ yield "hook_session_open"
179
+ elif event == "end":
180
+ yield "hook_session_end"
172
181
 
173
182
 
174
183
  _RUN_CACHE: dict = {}
@@ -0,0 +1,103 @@
1
+ """SessionEnd hook: record that a host session ended (D0b, v2.126.0).
2
+
3
+ Claude Code's ``SessionEnd`` event delivers on stdin::
4
+
5
+ {
6
+ "session_id": "<Claude Code UUID>",
7
+ "transcript_path": "...",
8
+ "cwd": "...",
9
+ "hook_event_name": "SessionEnd",
10
+ "reason": "clear" | "logout" | "prompt_input_exit" | "other"
11
+ }
12
+
13
+ Codex's ``SessionEnd`` carries its thread id in the same ``session_id``
14
+ field; the dispatcher routes both here (``hook_dispatch._routes``, event
15
+ ``end``).
16
+
17
+ Why this exists: this repo's activity log held 525 ``session_start`` rows
18
+ and 14 ``session_save`` rows — ``session_save`` is only written when a human
19
+ ends a session from the hub, so for a client the end of a conversation was
20
+ invisible. Two records fix that:
21
+
22
+ - ``{type: "session_end", session_id: <C3 id if known>, host_session_id,
23
+ reason, source}`` appended to ``.c3/activity_log.jsonl``. The C3 id comes
24
+ from the link file the MCP server wrote at session start
25
+ (``services.host_sessions``); it is ``""`` when the server never learned
26
+ the host id.
27
+ - a ``session`` notification (``kind="session"``, ``ref_id=host id``,
28
+ severity ``info``, title ``Session ended <short id>``) in
29
+ ``.c3/notifications.jsonl`` — the write that wakes a ``/feed?wait=``
30
+ client.
31
+
32
+ Runs in a short-lived subprocess with a few seconds of budget; every step is
33
+ best-effort and nothing here can fail the session that is already ending.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ sys.path.insert(0, str(Path(__file__).parent.parent))
41
+
42
+ from cli._hook_utils import detect_host, find_project, log_hook_error # noqa: E402
43
+
44
+ SHORT_ID_CHARS = 8
45
+
46
+
47
+ def short_id(session_id: str) -> str:
48
+ return str(session_id or "")[:SHORT_ID_CHARS] or "?"
49
+
50
+
51
+ def run(payload: dict, project_path: Path | None = None):
52
+ """Core logic — importable by the dispatcher and tests. Returns None."""
53
+ project = find_project(payload, project_path)
54
+ if project is None:
55
+ return None
56
+ host = detect_host(payload)
57
+ host_sid = str(payload.get("session_id") or "").strip()
58
+ reason = str(payload.get("reason") or "").strip()
59
+ c3_sid = ""
60
+ try:
61
+ from services.host_sessions import linked_c3_session_id
62
+ c3_sid = linked_c3_session_id(project, host, host_sid)
63
+ except Exception:
64
+ c3_sid = ""
65
+ try:
66
+ from services.activity_log import ActivityLog
67
+ ActivityLog(str(project)).log("session_end", {
68
+ "session_id": c3_sid,
69
+ "host_session_id": host_sid,
70
+ "reason": reason,
71
+ "source": host,
72
+ })
73
+ except Exception as exc:
74
+ log_hook_error("hook_session_end", exc)
75
+ try:
76
+ from services.notifications import notify
77
+ notify(project, agent="session", severity="info",
78
+ title=f"Session ended {short_id(host_sid)}",
79
+ message=(f"{host} session {host_sid or '?'} ended"
80
+ + (f" ({reason})" if reason else "")
81
+ + (f"; C3 session {c3_sid}" if c3_sid else "")),
82
+ kind="session", ref_id=host_sid)
83
+ except Exception:
84
+ pass
85
+ return None
86
+
87
+
88
+ def main() -> None:
89
+ import json
90
+ try:
91
+ data = json.load(sys.stdin)
92
+ except Exception as exc:
93
+ log_hook_error("hook_session_end", exc)
94
+ sys.exit(0)
95
+ try:
96
+ run(data)
97
+ except Exception as exc:
98
+ log_hook_error("hook_session_end", exc)
99
+ sys.exit(0)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
@@ -0,0 +1,97 @@
1
+ """SessionStart hook: record that a host session opened (D0b, v2.126.0).
2
+
3
+ Claude Code's ``SessionStart`` event delivers on stdin::
4
+
5
+ {
6
+ "session_id": "<Claude Code UUID>",
7
+ "transcript_path": "...",
8
+ "cwd": "...",
9
+ "hook_event_name": "SessionStart",
10
+ "source": "startup" | "resume" | "clear" | "compact"
11
+ }
12
+
13
+ Codex's ``SessionStart`` carries its thread id in the same ``session_id``
14
+ field and no ``source``; the dispatcher routes both here (event ``start``).
15
+
16
+ Records:
17
+
18
+ - ``{type: "session_open", host_session_id, source: <host>, start_source}``
19
+ in ``.c3/activity_log.jsonl``. ``source`` is the HOST (``claude`` /
20
+ ``codex`` / ``gemini``), matching ``session_end``; the payload's own
21
+ ``source`` (startup / resume / clear) is kept as ``start_source``.
22
+ - a ``session`` notification (``kind="session"``, ``ref_id=host id``,
23
+ ``info``, title ``Session started <short id>``).
24
+
25
+ ``source: "compact"`` writes NOTHING: a compaction is the same session and
26
+ the same MCP process, and a "Session started" toast on every compaction
27
+ would teach the user to ignore the real ones.
28
+
29
+ The MCP server's own ``session_start`` row is untouched — it carries the C3
30
+ id and, when the host put its id in the environment, ``host_session_id``
31
+ too. This hook is what lets a client see the session before the runtime has
32
+ finished starting (~seconds), and it is the row the desktop's "MCP failed to
33
+ connect" sentinel starts its 30 s clock from.
34
+
35
+ Returns None always: SessionStart hook stdout becomes model context, and
36
+ this hook has nothing to tell the model.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import sys
41
+ from pathlib import Path
42
+
43
+ sys.path.insert(0, str(Path(__file__).parent.parent))
44
+
45
+ from cli._hook_utils import detect_host, find_project, log_hook_error # noqa: E402
46
+ from cli.hook_session_end import short_id # noqa: E402
47
+
48
+ _SILENT_SOURCES = {"compact"}
49
+
50
+
51
+ def run(payload: dict, project_path: Path | None = None):
52
+ """Core logic — importable by the dispatcher and tests. Returns None."""
53
+ start_source = str(payload.get("source") or "").strip().lower()
54
+ if start_source in _SILENT_SOURCES:
55
+ return None
56
+ project = find_project(payload, project_path)
57
+ if project is None:
58
+ return None
59
+ host = detect_host(payload)
60
+ host_sid = str(payload.get("session_id") or "").strip()
61
+ try:
62
+ from services.activity_log import ActivityLog
63
+ ActivityLog(str(project)).log("session_open", {
64
+ "host_session_id": host_sid,
65
+ "source": host,
66
+ "start_source": start_source,
67
+ })
68
+ except Exception as exc:
69
+ log_hook_error("hook_session_open", exc)
70
+ try:
71
+ from services.notifications import notify
72
+ notify(project, agent="session", severity="info",
73
+ title=f"Session started {short_id(host_sid)}",
74
+ message=(f"{host} session {host_sid or '?'} started"
75
+ + (f" ({start_source})" if start_source else "")),
76
+ kind="session", ref_id=host_sid)
77
+ except Exception:
78
+ pass
79
+ return None
80
+
81
+
82
+ def main() -> None:
83
+ import json
84
+ try:
85
+ data = json.load(sys.stdin)
86
+ except Exception as exc:
87
+ log_hook_error("hook_session_open", exc)
88
+ sys.exit(0)
89
+ try:
90
+ run(data)
91
+ except Exception as exc:
92
+ log_hook_error("hook_session_open", exc)
93
+ sys.exit(0)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
cli/mcp_server.py CHANGED
@@ -100,6 +100,92 @@ def _build_instructions(ide_name: str) -> str:
100
100
  )
101
101
 
102
102
 
103
+ # ── Host session id (the join key between hook rows and MCP rows) ──────────
104
+ # The PreToolUse hooks write .c3/enforcement_state.json with `session_id` =
105
+ # the host's id for the session that just called. When the host also put its
106
+ # id in this process's environment (CLAUDE_CODE_SESSION_ID / CODEX_THREAD_ID)
107
+ # the SessionManager already carries it and no file is read. Otherwise the
108
+ # state file is consulted at most once per _HOST_SID_TTL_S — a tool call is
109
+ # far hotter than that, and a per-call read would be a stat+parse on every
110
+ # c3_* invocation for a value that changes once per session.
111
+ _HOST_SID_TTL_S = 5.0
112
+ _host_sid_cache = {"value": "", "read_at": 0.0, "linked": ""}
113
+ _host_sid_lock = threading.Lock()
114
+
115
+
116
+ def _host_session_id(svc) -> str:
117
+ """The host session id for this process, or ``""`` when nobody knows it."""
118
+ try:
119
+ session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
120
+ env_sid = str(session.get("host_session_id") or "").strip()
121
+ except Exception:
122
+ env_sid = ""
123
+ if env_sid:
124
+ _link_host_session(svc, env_sid)
125
+ return env_sid
126
+ now = time.monotonic()
127
+ with _host_sid_lock:
128
+ if now - _host_sid_cache["read_at"] < _HOST_SID_TTL_S:
129
+ return _host_sid_cache["value"]
130
+ _host_sid_cache["read_at"] = now
131
+ value = ""
132
+ try:
133
+ import json as _json
134
+
135
+ from cli._hook_utils import ENFORCEMENT_STATE_FILE
136
+ state_path = Path(svc.project_path) / ENFORCEMENT_STATE_FILE
137
+ if state_path.is_file():
138
+ data = _json.loads(state_path.read_text(encoding="utf-8"))
139
+ if isinstance(data, dict):
140
+ value = str(data.get("session_id") or "").strip()
141
+ except Exception:
142
+ value = ""
143
+ with _host_sid_lock:
144
+ _host_sid_cache["value"] = value
145
+ if value:
146
+ _link_host_session(svc, value)
147
+ return value
148
+
149
+
150
+ def _link_host_session(svc, host_sid: str) -> None:
151
+ """Write the host→C3 link file once per distinct host id (services.host_sessions)."""
152
+ with _host_sid_lock:
153
+ if _host_sid_cache["linked"] == host_sid:
154
+ return
155
+ _host_sid_cache["linked"] = host_sid
156
+ try:
157
+ session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
158
+ c3_id = str(session.get("id") or "")
159
+ if c3_id:
160
+ from services.host_sessions import write_link
161
+ write_link(svc.project_path, getattr(svc, "ide_name", "") or _IDE_NAME, host_sid, c3_id)
162
+ except Exception:
163
+ pass
164
+
165
+
166
+ def _notify_mcp_ready(svc) -> None:
167
+ """``kind="mcp"`` / ``C3 connected`` — the runtime is ready to serve (D0b).
168
+
169
+ ref_id is the C3 session id, the same value ``session_start`` carries;
170
+ the host id rides in the message when known. Best-effort: a notification
171
+ failure must never keep the server from serving.
172
+ """
173
+ try:
174
+ session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
175
+ c3_id = str(session.get("id") or "")
176
+ host_sid = _host_session_id(svc)
177
+ short = (host_sid or c3_id)[:8] or "?"
178
+ from services.notifications import notify
179
+ notify(svc.project_path, agent="c3", severity="info",
180
+ title=f"C3 connected {short}",
181
+ message=(f"C3 {C3_VERSION} MCP runtime ready for {_IDE_NAME}"
182
+ f" (session {c3_id}"
183
+ + (f", host {host_sid}" if host_sid else "") + ")"),
184
+ kind="mcp", ref_id=c3_id)
185
+ except Exception:
186
+ pass
187
+
188
+
103
189
  @asynccontextmanager
104
190
  async def lifespan(server):
105
191
  """Initialize all services, auto-start session, start file watcher."""
@@ -223,9 +309,16 @@ async def lifespan(server):
223
309
  services.auto_memory = AutoMemory(services.memory, services.session_mgr, auto_mem_cfg)
224
310
 
225
311
  if services.session_mgr.current_session:
312
+ # host_session_id (the Claude Code UUID / Codex thread id) joins this
313
+ # row to the hooks' session_open / session_end rows, which only know
314
+ # the host id. Absent when the host did not put its id in the
315
+ # environment; the link file below is written lazily in that case
316
+ # the first time a hook reveals it (see _host_session_id).
317
+ _host_sid = _host_session_id(services)
226
318
  services.activity_log.log("session_start", {
227
319
  "session_id": services.session_mgr.current_session["id"],
228
320
  "source_system": started_session.get("source_system", ""),
321
+ **({"host_session_id": _host_sid} if _host_sid else {}),
229
322
  })
230
323
 
231
324
  # Auto-restore latest snapshot if recent (< 30 min).
@@ -288,6 +381,12 @@ async def lifespan(server):
288
381
  )
289
382
  _t.Thread(target=_bg_validation_sweep, daemon=True, name="c3-validate-sweep").start()
290
383
 
384
+ # Runtime is ready to serve: say so where a client can hear it. A desktop
385
+ # "MCP failed to connect" sentinel keys on a session_open with no `mcp`
386
+ # notification within 30 s, so this must be the LAST thing before the
387
+ # handshake proceeds and must never be skipped on a partial init.
388
+ _notify_mcp_ready(services)
389
+
291
390
  try:
292
391
  yield services
293
392
  finally:
@@ -430,8 +529,22 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
430
529
  call_ok = not _failed(response)
431
530
  except Exception:
432
531
  call_ok = True
532
+ # Both session ids on every tool_call row (D0b): C3's own id joins the
533
+ # row to session_start / the saved session file, the host id joins it to
534
+ # the hooks' session_open / session_end rows. Either may be absent.
535
+ _ids: dict = {}
536
+ try:
537
+ _c3_sid = str((svc.session_mgr.current_session or {}).get("id") or "")
538
+ if _c3_sid:
539
+ _ids["session_id"] = _c3_sid
540
+ _host_sid = _host_session_id(svc)
541
+ if _host_sid:
542
+ _ids["host_session_id"] = _host_sid
543
+ except Exception:
544
+ pass
433
545
  svc.activity_log.log("tool_call", {"tool": tool_name, "args": args,
434
- "result_summary": summary, "ok": call_ok})
546
+ "result_summary": summary, "ok": call_ok,
547
+ **_ids})
435
548
  tracker = getattr(svc, "time_tracker", None)
436
549
  if tracker is not None:
437
550
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.124.0
3
+ Version: 2.126.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
@@ -380,7 +380,7 @@ C3 also records real per-tool usage to `.c3/tool_telemetry.jsonl`, so estimates
380
380
  ## Security & privacy
381
381
 
382
382
  - **All web servers bind `127.0.0.1` by default** and are guarded against browser-based attacks even on loopback — a Host-header allowlist (defeats DNS rebinding) plus an Origin/Referer check on every request (defeats cross-origin CSRF), with scoped, non-wildcard CORS. There is still **no user authentication**, so do not expose these servers to an untrusted network without auth/TLS in front. Binding to a non-loopback interface is opt-in and warned at startup. *(Hardening added in v2.33.0.)*
383
- - **Mobile gateway (`/api/mobile/*`)** — the Oracle exposes a companion-app surface for the C3 mobile app: merged cross-project feed, project overview, PM board read/write, digest, notification ack, plus the security surface (credential vault, Access Guard rules and masks, tool discipline). Unlike the legacy Oracle GETs it requires the **Discovery Bearer token on every method, GETs included**, validates every project path against the registry, and audits every write to the project's activity log and edit ledger. Pair the app from Oracle Settings → *Mobile app* (QR code carrying URL + token). Remote reachability is the same recipe as the Discovery API: set `bind_host` to the Tailscale/LAN interface IP (avoid `0.0.0.0`) and list that address in `allowed_hosts`, both in `~/.c3/oracle/config.json`. Disable the whole surface with `"mobile_api_enabled": false`, or any subsystem with `mobile_credentials_enabled` / `mobile_access_enabled` / their `_write` twins.
383
+ - **Mobile gateway (`/api/mobile/*`)** — the Oracle exposes a companion-app surface for the C3 mobile app: merged cross-project feed, project overview, PM board read/write, digest, notification ack, plus the security surface (credential vault, Access Guard rules and masks, tool discipline). Unlike the legacy Oracle GETs it requires a **Bearer token on every method, GETs included** — the Discovery token, or (v2.125.0+) a **per-client token**: one credential per paired device, stored hashed in `~/.c3/oracle/clients.json`, revocable one at a time, and carrying a kind (`mobile` or `desk`) that override decisions are audited under. It validates every project path against the registry, and audits every write to the project's activity log and edit ledger. Pair the app from Oracle Settings → *Mobile app* (the QR carries the URL and a freshly minted per-device token; a legacy Discovery-token code is one button away). A same-machine client such as C3 Desk pairs itself with `POST /api/mobile/clients` and the owner-only `~/.c3/oracle/bootstrap.key`. Remote reachability is the same recipe as the Discovery API: set `bind_host` to the Tailscale/LAN interface IP (avoid `0.0.0.0`) and list that address in `allowed_hosts`, both in `~/.c3/oracle/config.json`; with such a bind the Oracle also listens on `127.0.0.1` (`loopback_listener`, default on) so local clients are not refused. Disable the whole surface with `"mobile_api_enabled": false`, or any subsystem with `mobile_credentials_enabled` / `mobile_access_enabled` / their `_write` twins.
384
384
  - **The mobile gateway never returns a credential value.** It has no reveal route by design: entries cross the wire through an allowlist serializer, and a value can only be identified by its length and an on-demand fingerprint. Two further levers ship **off** — `mobile_creds_agent_readable_raise` (raising `agent_readable` from the phone) and `mobile_access_global_scope` (machine-wide access rules) — because each exceeds what a leaked Bearer token could otherwise reach. Deliberately absent from the surface: bulk `.env` import, denial-counter clearing, `set_builtin_disabled`, and the raw-content access preview.
385
385
  - **No telemetry by default.** The OSS package collects nothing. Opt-in Sentry crash reporting requires the `[telemetry]` extra plus both `SENTRY_DSN` and `C3_TELEMETRY_OPT_IN=1`; even then request bodies, local variables, and prompts are stripped.
386
386
  - **LLM memory distillation is local-first.** Cloud distillation (v2.51.0) is off by default and opt-in per project.
@@ -1,7 +1,7 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
- cli/_hook_utils.py,sha256=e48F6FQloFReUIMtmWsih2t54j9SROzAaHtMkEuttDs,20489
2
+ cli/_hook_utils.py,sha256=Y6SHOe1X64PIqphxINfUg7rj5SwZjaBmqifctGtmCk8,21631
3
3
  cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
4
- cli/c3.py,sha256=TRLSfHOBi-9AxQUAaXYq46tFgaBd4WMKw0WIBnFijHI,416843
4
+ cli/c3.py,sha256=OScdyhrunDWN8ELs0FUgpS9KS5-iRzBWc8Cib0lu9GQ,418023
5
5
  cli/docs.html,sha256=X9M7qC7LWi8pPJNF-4yEM57jhuZ3WeTESECbdDOeNaA,143172
6
6
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
7
7
  cli/hook_access_guard.py,sha256=ba8-un6G9Ap8HCzx7nyhVq3c_G4sOtiVXRy0BzzX8-Y,23349
@@ -10,7 +10,7 @@ cli/hook_auto_snapshot.py,sha256=yGFqRANtw8qNqRNr-oCgpuyaUyTkSzIOFxXt5kauq4I,527
10
10
  cli/hook_c3_signal.py,sha256=8-CycalKBMORVITE3iODjWd31Xf34KLg04stIxFlGTw,2304
11
11
  cli/hook_c3read.py,sha256=bXLAVvERR0SoKrwcmZhpz78FyinteZWfm_ZL-QItSqc,3612
12
12
  cli/hook_codex_lifecycle.py,sha256=TdpBNITJHblP0OrqQ4Ffp6ZNKLs0gJb-Onh6H-mzB0w,2832
13
- cli/hook_dispatch.py,sha256=iditrmsQMEDYpXSRDT0tPdXRjL6mugpwIgf6gSHkszs,24072
13
+ cli/hook_dispatch.py,sha256=yK_6haXV2GjILEhmFNTlzeHO5eAWhl6PtaBUUckIy_w,24524
14
14
  cli/hook_edit_ledger.py,sha256=3ooM6Lv83Ntq_aeyrZdn-0FlZSsE_CrWeuB_V-SEAJk,12105
15
15
  cli/hook_edit_unlock.py,sha256=PwPRuCQPARZZRtH6wb2Xm-6ADpfrC-0rXs2uRmHAAmg,6883
16
16
  cli/hook_filter.py,sha256=v8ZSznK5StTfYYqae1l4V2bm-e9sy2RFfpbNr5KwE2U,4988
@@ -18,12 +18,14 @@ cli/hook_ghost_files.py,sha256=plbedFgIJ5tXEE_8USUE39RmyODh1UjIUFNRbUo4R8w,10291
18
18
  cli/hook_pretool_enforce.py,sha256=k9jEyi7ip7MnsbBK3jE0JEcLEitHMg2iMWnX56XhaBY,31329
19
19
  cli/hook_prompt_recall.py,sha256=LHyubu36pf5h3hbLBe0yH-yGhAlwLJdsc7hfa7WB1h4,3471
20
20
  cli/hook_read.py,sha256=ZWZldgqGSNz78COhlgETQ7yxdOW5vWIIHUet9gZKhxk,8176
21
+ cli/hook_session_end.py,sha256=toFEPDKRoutDuVOmuOgHmQ8CM0o7tF7yALhMxJz7VEM,3482
22
+ cli/hook_session_open.py,sha256=f7AYZ7UUo_jrujH40dZsLL9S559Nblfx-BpyIQPYRB8,3405
21
23
  cli/hook_session_stats.py,sha256=i1k7DM0RoQsHm5R8C8RUWvpEA7lzP-NPD95-fAARlnI,7273
22
24
  cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,6252
23
25
  cli/hub_server.py,sha256=4BxvRHCGNc87jOtWankOVjQLYNk-GhWI2OY1harC8ro,184548
24
26
  cli/hub_ui.html,sha256=nnGqWhWGCLfNisGUnoAlQFcBr6wPNsq-AEOR7uFtVkE,2942
25
27
  cli/mcp_proxy.py,sha256=r-Qhlie7gCtotex8yQUudCyBLRUK-IW6cNIW5RhBW0o,17648
26
- cli/mcp_server.py,sha256=3oxd0sQisBVnWRVix9TDBPgv_w-9dc-MIClJEd6igws,63714
28
+ cli/mcp_server.py,sha256=q5jQTFazMsWMe2rb2ngSCY9yPeV2MWOg--f_NV-SnCQ,68676
27
29
  cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
28
30
  cli/server.py,sha256=bsHvKGPXsBJi_MDLiw0IAEq8RWKIG56q72c7OKKTLug,173324
29
31
  cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
@@ -123,7 +125,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
123
125
  cli/ui/components/settings.js,sha256=ATbAjBlVIwCNpxq7s191b49a_INQV38iwmySqtJLYwY,79066
124
126
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
125
127
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
126
- code_context_control-2.124.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
128
+ code_context_control-2.126.1.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
127
129
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
128
130
  core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
129
131
  core/config.py,sha256=YmkcZwedz_lfDM0ZuI_f4xpe98ixPLcSQFcTCHgRiRg,19206
@@ -132,17 +134,19 @@ core/ide.py,sha256=y9J6dGTD_FpNDSHY38nn_UduP_Ey2iP5XZ9fU_rhY10,6227
132
134
  core/mcp_toml.py,sha256=VNZCjJsr4II_jX3cmjYzDh_OQ7Nk34mLnkg528Tl1GM,7047
133
135
  core/web_security.py,sha256=HwgK5aBATfvRIeO_4gWgzK77gKwGuzVlmiuNGm4gGxI,7884
134
136
  oracle/__init__.py,sha256=-OTD7Jh4mUMA4QgPGthPLWXttgZLpkIPhGQ87ZfHBx0,63
135
- oracle/config.py,sha256=F2m9GfgJbpkmXEecglWF3GYeHtPSVDY6HOXQpiMJ3B8,6324
137
+ oracle/config.py,sha256=cypUJHKAj1AODIuRKefdGI-myHrvEEi4OuNzTcsfMwo,6645
138
+ oracle/listeners.py,sha256=lV59aFuMddq-H8l8328NmDyfI79LaB-rcFyrv5cFmog,8054
136
139
  oracle/mcp_oracle.py,sha256=B5B-qSxi37outsuRHn1Ns6Gj8e7W593rmL34LMzuE8M,8980
137
- oracle/oracle_server.py,sha256=gL0DBXXmkGZPFSV54wQmy8hbeGPU662qBS-0nlHYMs0,49468
138
- oracle/oracle_ui.html,sha256=bfW7t9cgax-HNT5a41duYSUS_w0orSX3uinwPv-Fffs,71766
140
+ oracle/oracle_server.py,sha256=cSv-a7CrqyZCAlKEqJGXm3TPBep6zyULYmY6lOeu5hM,53344
141
+ oracle/oracle_ui.html,sha256=C4NZbQzo1QDx93EZk9kJaeD4IRWUz7OikYZRcaacI4E,72360
139
142
  oracle/services/__init__.py,sha256=Nb4POd1_YIwLVYsGfr-DiK-iKTelkU0fh9m7wjeLQHA,23
140
143
  oracle/services/activity_reporter.py,sha256=ZAxNFyUy7QRP28anQLRbKEwm5SGG97KIQYFQDLjYqlM,11729
141
144
  oracle/services/api_auth.py,sha256=1PW3pG--1DJb_F6qMhP3gBTYHxxPE2bsHmVmIhC81Y8,3566
142
145
  oracle/services/c3_bridge.py,sha256=67v_l44Yjep4Ibh_xwQVLlz9lEifqiDUoXBWIxcfKzQ,18878
143
146
  oracle/services/chat_engine.py,sha256=zvjyFsXGwqRHsvggBwQ6L0hcOovQe0AKyOy2ZdOU0No,55974
144
- oracle/services/chat_poll.py,sha256=GZJdYvB09ifFxSmbwEb52tLZm28wYmYIwQh48e6SmkU,15597
147
+ oracle/services/chat_poll.py,sha256=VrfWjBrJQgAdwNZuHAy7qzN-qAqNByG3Q7WqoItCnfs,15859
145
148
  oracle/services/chat_store.py,sha256=mXH-eWo78dKnVUcPVW_1i0JD2nZYVss5xcyd3HM1KGc,10065
149
+ oracle/services/client_tokens.py,sha256=upPr7ssTb0HK40Wd1E2O-r0JXSgPrZGXghM_thujTOY,8358
146
150
  oracle/services/cross_memory.py,sha256=F8JeYkEFSwQL3iGS9KV1onaMSFj3opr6f3nCxwvznV0,5484
147
151
  oracle/services/discovery_audit.py,sha256=zhKJfLfedTS9LIjNimLERidg0iqAC565svEvp8-IA98,5778
148
152
  oracle/services/federated_graph.py,sha256=QTaP31Z1pa_J2rlI9BOBBtDHta887AAs4v4sCaDSN8g,20660
@@ -151,14 +155,14 @@ oracle/services/insight_engine.py,sha256=lwcdXtjt3CSZLqHVpXx6SG5gDhZYUKwXt4Z8dGy
151
155
  oracle/services/local_session.py,sha256=nkawOjDbotUp5wtZQnC1wPDw3HopNYMkoYqWpP5ToCU,9227
152
156
  oracle/services/memory_reader.py,sha256=rSEmoIXQu2JkolHJIQWpn0piPJpyuf1KZvW_9liQwkU,3727
153
157
  oracle/services/memory_writer.py,sha256=hpvYsxjhl293jEjGcRKfjG5njUaAOXKqbmJaUu1WdUU,6997
154
- oracle/services/mobile_api.py,sha256=9nYE84FRHtgiwv6b9Hs17o3pKYXtPlYCn1Xny_gMLhk,120326
158
+ oracle/services/mobile_api.py,sha256=PPAzTbPUYlTNXBw9gW1bck2QxSPr3m7zJZA9ChjiY-w,134590
155
159
  oracle/services/project_scanner.py,sha256=PkYJtwOLRME1EzwRy3GIkYk6Xw0u1_BzFcaMJ5gJ1_s,6118
156
160
  oracle/services/review_agent.py,sha256=m_fzoHowpiibf0_iz3dF72LWSrwB_FbDd1am-SbeWXg,11273
157
161
  oracle/services/tool_executor.py,sha256=xtAkBWkclh_FwOgzprjGkyRIV8-A7Lc3bz0UFPShrv0,1113
158
162
  oracle/services/tool_registry.py,sha256=mJUZSS99PDH8whkndNxcQXT1uoNxPviE3MotGzj2jtk,26046
159
163
  oracle/ui/activity.js,sha256=zbJjqkY-tTQS4YKcdSEr2Z5zAbwbOIPyTNzldQSPU7I,4830
160
164
  oracle/ui/agents.js,sha256=RNnsGUVg5DlRYiv-Ovzb3W4yyP4z0qTX3o7Y1_BgZpo,4538
161
- oracle/ui/app.js,sha256=fyfFL7YNbl_z1WSUOMXScp4qcavuFVg5cnA7D1i3zYU,795
165
+ oracle/ui/app.js,sha256=Ry_V4qn7WnxmeTFN7xutMQJTgGVaR0vZzidgCxtwp1c,816
162
166
  oracle/ui/busy.js,sha256=ePFGqEheHpn-E971pN_9DbQvvkxD-d2V7Efbw68bpo0,4122
163
167
  oracle/ui/core.js,sha256=sDrD1hWKMliOO6rjmTsaTULXauKzphc5tm9NEcNTcd4,5478
164
168
  oracle/ui/crossgraph.js,sha256=7yBCciuEzZ0LYspWMO0xTe_GuPQ_NccnIeRrqEq5Qxs,9234
@@ -166,7 +170,7 @@ oracle/ui/header.js,sha256=xMymi7x7mvoImlL-1iOfEwmwp7AJkiCp31xNLlGoIgE,4189
166
170
  oracle/ui/insights.js,sha256=zACyfGz9-4y2eFd6OM1IE-L_HyL1wnqo46nJDyRx7-M,2408
167
171
  oracle/ui/projects.js,sha256=ULE6Py1iULVmRGy0mCnEAxKNwHvn-baUYlNDF5xGYM8,13002
168
172
  oracle/ui/qrcode.js,sha256=GK45n4EYK8nekW6cd7GV3yDMWNby1VpisIWimfG_F4A,56694
169
- oracle/ui/settings.js,sha256=rPm18C6QmVJBPICxABou2MPjCITnmo8Xdkgo8klrU8E,9432
173
+ oracle/ui/settings.js,sha256=EzKtxDbTL2SwqfPhRJlb7n2znb-77_a9npuGle1z1h8,12401
170
174
  oracle/ui/suggestions.js,sha256=ItQTlyd6LS_QxvbJmNMYeHY3cl0lSRgBdR53XKkBpP0,2332
171
175
  oracle/ui/theme_tabs.js,sha256=sIFnBLgI_Hp7c-GRZrWN2v4pvRBJUnIhhwGG91qydKI,3347
172
176
  oracle/ui/chat/conversations.js,sha256=FOpXNrIeiJUR9VueBYEuLml-VfJfuHo05ZVkT-x7XRI,5907
@@ -197,7 +201,7 @@ services/ci_failures.py,sha256=32aJ_R5S-JE4fErhzs06ZQzhBVLawZ4brtXtdoJrnE0,8087
197
201
  services/ci_github.py,sha256=2yXsVAiWMIvhxH7xsMJIO_3DxCvZbKJcSWt4zXyyWxE,6699
198
202
  services/ci_impact.py,sha256=9_CJv2sNza0_qW7cSsMnxhJy_AGflDONjL9jKQeoWmc,10966
199
203
  services/ci_intel.py,sha256=frr1jL-FXU_JNUoRW8TwnZo04fqUElAGGEsDEvgIeUs,7161
200
- services/ci_runner.py,sha256=egkFGaOjAa45KIMFbGX7108R2wUQwUuLgJ4Iq7ygx9I,40768
204
+ services/ci_runner.py,sha256=xGQ74iTo8QPm8u5J-5QBWofMLIOMzNXVqggwfuU3q-0,42270
201
205
  services/ci_workflow.py,sha256=xfeUvzcm9GHiDdi7QAbg4SAvnzzuCCV3bmwdAMUTZKA,31133
202
206
  services/circuit_breaker.py,sha256=ES6PpjL40gfDhtK88GeS6GFzsz5EOEEkZsB-DzX_tbw,3179
203
207
  services/claude_md.py,sha256=U9Ogzktmqz1D75L_vEBuadNqddrhUY1v_e3KbPn46EE,61453
@@ -221,6 +225,7 @@ services/error_reporting.py,sha256=HZ3ru8i5RLf8nq2R4iRnTs5sm1blUxknSbv5hdxuxs0,4
221
225
  services/file_map.py,sha256=j37DTR-UE_PMNj7vG2HiHucpwpJNaKgiD94UNUU-UXE,17091
222
226
  services/file_memory.py,sha256=4n2y6Sci4ThP1ob8dh68bEq80ui5I50iWpQjoIbGI84,38565
223
227
  services/git_context.py,sha256=FFdA8Y46op4DYsUnZsXLTqxIjgxb0q_d-hkFTbosC8Q,9518
228
+ services/host_sessions.py,sha256=IPpNmeYA26pWUrSnJNb2sLot9VgexYJwh9ezJL2H798,3684
224
229
  services/hub_service.py,sha256=z2MZ2Rg9NLNXxQ17e05XGF_f6qSTDWLYtoC3WpuQfGU,2634
225
230
  services/index_store.py,sha256=SWHHKQB_wYww1ubtKgkLo5FFEqaWhPbw0OAN8x3gk9Q,16453
226
231
  services/indexer.py,sha256=AByWHaLy-0c8nxZMJF08a11d-cd106I__2ZIucUDNMM,59946
@@ -241,7 +246,7 @@ services/memory_grounder.py,sha256=vM7_n_Ezkkc3oGQ1MECUT9Bpt2K1fsNx2kWiumAW6Qc,1
241
246
  services/memory_queue.py,sha256=sSFh2oU_BLbv90jXhPbRWNIS2nlUi468-INw96GFIwU,3852
242
247
  services/memory_scorer.py,sha256=32wccyVFBgcNwKdjwj_7RDnPukxSWBng8ohsRPTpQMM,9497
243
248
  services/metrics.py,sha256=X4D5TgcQSFaFLbBoa1s3bAWf00DMMjq3IrOACrK-uDg,3289
244
- services/notifications.py,sha256=DwwE07-BI5-9s7G00onsiIyy0XHjppDFjWZ0SESoftQ,15508
249
+ services/notifications.py,sha256=2AxYxaebdwvz5ysPjIEDUoEDO0PE84MAXvSb4-hkkIY,17154
245
250
  services/ollama_bridge.py,sha256=16ukWS98H4wFulOphUJDuXhN61GCiay793XLoyxTU-s,17455
246
251
  services/ollama_client.py,sha256=UiC5Abca622-ac-4goCkAjwo7iUye9g9JGFMN6Ea08M,7983
247
252
  services/ollama_credentials.py,sha256=EQx0YljYRrRBJ2voALlVTv11UBjfw-A6N7k7EP7fUFE,2770
@@ -268,7 +273,7 @@ services/scanner.py,sha256=lH0aiDCdCRruKS-VTAD_LsF4zrhMLpMRhFlOZZlw4VM,9613
268
273
  services/session_benchmark.py,sha256=KUcWWC5X088p_ao6DgglUMwj7jCzdecPKxJ7XiGKob0,106255
269
274
  services/session_manager.py,sha256=KDquAwr7Z7LpuATzipe6bQhzznr-FR-21fCCtB6IpEQ,57281
270
275
  services/session_preloader.py,sha256=DsTAXMKVtrX9yu1sEFojYDi9-jkSAj1Ylt9JTy57Dow,9883
271
- services/shell_jobs.py,sha256=p95Sr_lCA04ZkZh3UezeIxxw72_ClDzASaH-T9CV8CA,48581
276
+ services/shell_jobs.py,sha256=YaC-IOJr_OE4AB95IewtSVx1bZ3wmPIOiNyXiaxjp1g,52986
272
277
  services/shell_output.py,sha256=lLvENKqS9N7-hsVw9M7fWUXhBVPaaGRmU9dRx3Vr4Dk,42558
273
278
  services/subprojects.py,sha256=pjGbuWLqC81QxYMai9ZbNxmTJTuwzM7QNiyAqeZG-Yg,41218
274
279
  services/task_store.py,sha256=QoyhLcAyd-JZxn1nMfnZ11tTLgFqY-xwmlx1q8w_1p8,49477
@@ -306,8 +311,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
306
311
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
307
312
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
308
313
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
309
- code_context_control-2.124.0.dist-info/METADATA,sha256=aoNZXvOHSJK4ylPra3DMz2K2FMUEkKIKiAYLOUYOWcg,27067
310
- code_context_control-2.124.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
311
- code_context_control-2.124.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
312
- code_context_control-2.124.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
313
- code_context_control-2.124.0.dist-info/RECORD,,
314
+ code_context_control-2.126.1.dist-info/METADATA,sha256=j071MjZbiInNKHuguLH8fjC_G0gUerdofSKVUQnqXkA,27648
315
+ code_context_control-2.126.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
316
+ code_context_control-2.126.1.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
317
+ code_context_control-2.126.1.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
318
+ code_context_control-2.126.1.dist-info/RECORD,,
oracle/config.py CHANGED
@@ -11,6 +11,11 @@ DEFAULTS = {
11
11
  "port": 3331,
12
12
  # ── Discovery API (external LLM tool surface, v2.32.0) ──
13
13
  "bind_host": "127.0.0.1", # loopback only by default (was 0.0.0.0)
14
+ # When bind_host is a specific non-loopback address (a Tailscale / LAN
15
+ # IP), ALSO serve the same app on 127.0.0.1 so same-machine clients (the
16
+ # desk tray client, `c3 oracle open`, curl) are not refused. No effect on
17
+ # a loopback or wildcard bind. See oracle/listeners.py.
18
+ "loopback_listener": True,
14
19
  "api_enabled": True, # expose /api/discovery/* REST surface
15
20
  "api_require_auth": True, # require Bearer token on /api/discovery/*
16
21
  "api_max_tier": "action", # cap exposed tools: "read" | "action"