code-context-control 2.71.0__py3-none-any.whl → 2.74.0__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
@@ -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.71.0"
95
+ __version__ = "2.74.0"
96
96
 
97
97
 
98
98
  def _compress_file_cli(compressor, path, mode="smart", **kw):
cli/tools/_grants.py ADDED
@@ -0,0 +1,61 @@
1
+ """Override grants on the MCP content surface (override-requests.md P2a).
2
+
3
+ The hooks have consulted grants since P1. The `c3_*` content tools have not:
4
+ they called the evaluator and refused without ever asking whether the user
5
+ had already approved this exact call. §13's coverage matrix recorded that as
6
+ "not yet — P2a", which meant an approved request did nothing for any agent
7
+ working through MCP — the grant was minted, and then no one looked at it.
8
+
9
+ This module is the single place those tools ask. It is deliberately thin:
10
+ all the policy ordering, TTL clamping, use accounting and audit lives in
11
+ `services.override_grants.gate_access`, which is the same call the hooks
12
+ make. Two implementations of "may I" is how the two surfaces drift apart.
13
+
14
+ `session_id` lives here for the same reason. It was duplicated in edit.py,
15
+ locks.py and override.py, each carrying a comment saying it must match the
16
+ others exactly — and P2a makes that invariant load-bearing rather than
17
+ merely tidy: a grant is minted under the session id `c3_override` computed
18
+ and consumed under the one `c3_edit` computes. If those two ever disagree,
19
+ every approval silently fails to apply.
20
+ """
21
+
22
+ import os
23
+
24
+
25
+ def session_id(svc) -> str:
26
+ """This agent's identity, for leases and for grants.
27
+
28
+ Falls back to the process id, never to "". Two agents that both resolved
29
+ to "" would count as ONE session and stop blocking each other — the exact
30
+ opposite of what a lock is for. Each Claude Code session runs its own
31
+ c3-mcp process, so the pid is a faithful stand-in.
32
+
33
+ It is also the right identity for c3_project: that proxy builds a runtime
34
+ for the TARGET project but runs inside the CALLER's process, so the pid
35
+ keeps the edit attributed to the agent that actually asked for it.
36
+ """
37
+ session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
38
+ return str(session.get("id", "") or "") or f"pid-{os.getpid()}"
39
+
40
+
41
+ def allow(svc, denial, *, tool: str, op: str, path) -> str | None:
42
+ """The `[c3-override:granted]` line when a live grant permits this exact
43
+ call, or None to stay denied.
44
+
45
+ Consumes a use on success — so callers must invoke this only when they
46
+ are about to proceed, never speculatively. Ordering inside `gate_access`
47
+ is policy first, grants second, so a grant is void the instant the policy
48
+ or its layer is switched off (§12.8).
49
+
50
+ Fail-closed by construction: any error resolving the grant store leaves
51
+ the caller on its ordinary refusal path. A grant that cannot be read is
52
+ not a grant.
53
+ """
54
+ if denial is None:
55
+ return None
56
+ try:
57
+ from services import override_grants as og # noqa: PLC0415 — lazy
58
+ return og.gate_access(svc.project_path, denial, tool=tool, op=op,
59
+ path=str(path), session_id=session_id(svc))
60
+ except Exception:
61
+ return None
cli/tools/compress.py CHANGED
@@ -8,6 +8,7 @@ import sys
8
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
9
  from pathlib import Path
10
10
 
11
+ from cli.tools import _grants
11
12
  from cli.tools._helpers import finalize_with_tokens, show_token_ratios
12
13
  from core import count_tokens
13
14
  from services import access_guard
@@ -118,7 +119,9 @@ def _compress_single(file_path: str, mode: str, svc, finalize, maybe_facts) -> s
118
119
  # Access Guard: read verdict up front — covers the map/dense_map paths
119
120
  # that never reach compressor.compress_file (docs/access-guard.md §3).
120
121
  _verdict = access_guard.verdict(file_path, "read", svc.project_path)
121
- if _verdict.denial:
122
+ if _verdict.denial and not _grants.allow(svc, _verdict.denial,
123
+ tool="c3_compress", op="read",
124
+ path=file_path):
122
125
  return finalize("c3_compress", {"file_path": file_path, "mode": mode},
123
126
  access_guard.refusal(_verdict.denial, file_path, "read"),
124
127
  "access-denied")
cli/tools/edit.py CHANGED
@@ -22,25 +22,22 @@ import threading
22
22
  from contextlib import contextmanager
23
23
  from pathlib import Path
24
24
 
25
+ from cli.tools import _grants
25
26
  from services import access_guard, agent_locks
26
27
  from services import credential_store as _cs
27
28
  from services.task_store import _FileLock
28
29
 
29
30
 
30
31
  def _session_id(svc) -> str:
31
- """This agent's lease identity.
32
+ """This agent's lease identity — see `cli.tools._grants.session_id`.
32
33
 
33
- Falls back to the process id, never to "". Two agents that both resolved
34
- to "" would count as ONE session and stop blocking each other — the exact
35
- opposite of what a lock is for. Each Claude Code session runs its own
36
- c3-mcp process, so the pid is a faithful stand-in.
37
-
38
- It is also the right identity for c3_project: that proxy builds a runtime
39
- for the TARGET project but runs inside the CALLER's process, so the pid
40
- keeps the edit attributed to the agent that actually asked for it.
34
+ Was defined here and copied into locks.py and override.py, each with a
35
+ comment saying it had to match. P2a makes that invariant load-bearing:
36
+ a grant is minted under the id c3_override computes and consumed under
37
+ the one c3_edit computes, so a divergence would make every approval
38
+ silently fail to apply. One definition, three callers.
41
39
  """
42
- session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
43
- return str(session.get("id", "") or "") or f"pid-{os.getpid()}"
40
+ return _grants.session_id(svc)
44
41
 
45
42
  # ── Same-file serialization ───────────────────────────────────────────────
46
43
  # In-process locks, keyed by resolved absolute path string.
@@ -325,7 +322,8 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
325
322
  # (never replaces) any dedicated vault-file guard.
326
323
  op = "write" if path.exists() else "create"
327
324
  denial = access_guard.check(str(path), op, svc.project_path)
328
- if denial:
325
+ if denial and not _grants.allow(svc, denial, tool="c3_edit", op=op,
326
+ path=str(path)):
329
327
  return finalize("c3_edit", {"file": file_path},
330
328
  access_guard.refusal(denial, file_path, op),
331
329
  "access-denied")
cli/tools/filter.py CHANGED
@@ -10,6 +10,7 @@ import json
10
10
  import re
11
11
  from pathlib import Path
12
12
 
13
+ from cli.tools import _grants
13
14
  from cli.tools._helpers import finalize_with_tokens, show_token_ratios
14
15
  from core import count_tokens
15
16
  from services import access_guard
@@ -31,7 +32,9 @@ def handle_filter(file_path: str, text: str, pattern: str, max_lines: int,
31
32
  # Access Guard: read verdict (docs/access-guard.md §3), checked before
32
33
  # existence so probes can't distinguish missing from denied (R2).
33
34
  denial = access_guard.check(file_path, "read", svc.project_path)
34
- if denial:
35
+ _granted = _grants.allow(svc, denial, tool="c3_filter", op="read",
36
+ path=file_path) if denial else None
37
+ if denial and not _granted:
35
38
  return finalize("c3_filter", {"file": file_path},
36
39
  access_guard.refusal(denial, file_path, "read"),
37
40
  "access-denied")
cli/tools/impact.py CHANGED
@@ -9,6 +9,7 @@ import subprocess
9
9
  import sys
10
10
  from pathlib import Path
11
11
 
12
+ from cli.tools import _grants
12
13
  from services import access_guard
13
14
 
14
15
  _SKIP_DIRS = frozenset({
@@ -115,7 +116,8 @@ def handle_impact(target: str, file_path: str, mode: str, svc, finalize) -> str:
115
116
  # Access Guard: refuse outright when the named source file is read-denied.
116
117
  if file_path:
117
118
  denial = access_guard.check(file_path, "read", str(project))
118
- if denial:
119
+ if denial and not _grants.allow(svc, denial, tool="c3_impact",
120
+ op="read", path=file_path):
119
121
  return finalize("c3_impact", {"target": target, "mode": mode},
120
122
  access_guard.refusal(denial, file_path, "read"),
121
123
  "access-denied")
cli/tools/locks.py CHANGED
@@ -12,17 +12,14 @@ and letting go early instead of waiting out the TTL.
12
12
  force_release is deliberately absent: it is a human override that bumps the
13
13
  fencing counter, and it lives in `c3 locks force-release` / the Hub tab.
14
14
  """
15
- import os
16
-
15
+ from cli.tools import _grants
17
16
  from services import agent_locks as al
18
17
 
19
18
 
20
19
  def _session_id(svc) -> str:
21
- """Must match cli/tools/edit.py._session_id exactly — a lease taken by
22
- c3_edit has to be recognised as ours by c3_locks, and vice versa. Never ""
23
- (see the note there: two anonymous agents would stop blocking each other)."""
24
- session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
25
- return str(session.get("id", "") or "") or f"pid-{os.getpid()}"
20
+ """A lease taken by c3_edit has to be recognised as ours by c3_locks, and
21
+ vice versa — so both read the one definition in `_grants`."""
22
+ return _grants.session_id(svc)
26
23
 
27
24
 
28
25
  def _split(paths: str) -> list:
cli/tools/override.py CHANGED
@@ -12,9 +12,9 @@ Layers that can never be escalated (the credential vault, Tier-0 denies, the
12
12
  dispatcher fail-closed deny, catastrophic shell blocks) are refused at
13
13
  creation with `[c3-override:not-escalatable]` and never reach a human.
14
14
  """
15
- import os
16
15
  import time
17
16
 
17
+ from cli.tools import _grants
18
18
  from services import access_guard as ag
19
19
  from services import override_policy as opol
20
20
  from services import override_requests as orq
@@ -28,9 +28,8 @@ _DEFAULT_TOOL = {"read": "Read", "write": "Edit"}
28
28
 
29
29
 
30
30
  def _session_id(svc) -> str:
31
- """Must match cli/tools/edit.py._session_id — the grant is bound to it."""
32
- session = getattr(getattr(svc, "session_mgr", None), "current_session", None) or {}
33
- return str(session.get("id", "") or "") or f"pid-{os.getpid()}"
31
+ """The identity the grant is bound to — one definition, in `_grants`."""
32
+ return _grants.session_id(svc)
34
33
 
35
34
 
36
35
  def _fmt(row: dict) -> str:
cli/tools/read.py CHANGED
@@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
+ from cli.tools import _grants
9
10
  from cli.tools._helpers import finalize_with_tokens, maybe_related_facts
10
11
  from core import count_tokens
11
12
  from services import access_guard
@@ -123,8 +124,14 @@ def handle_read(file_path: str, symbols: Any = None, lines: Any = None,
123
124
  # before existence so probes get the same refusal whether or not the
124
125
  # target exists (R2). Batch members hit this via the per-file dispatch,
125
126
  # so allowed members are served and denied ones carry the S1 line inline.
127
+ # A live grant (P2a) is consulted only on the DENIAL branch. The masked
128
+ # branch below is untouched on purpose: a mask is not a refusal to be
129
+ # lifted, it is a different view being served, and "approve once" has no
130
+ # meaning for it. Widening that is a separate decision, not a side effect.
126
131
  _verdict = access_guard.verdict(file_path, "read", svc.project_path)
127
- if _verdict.denial:
132
+ if _verdict.denial and not _grants.allow(svc, _verdict.denial,
133
+ tool="c3_read", op="read",
134
+ path=file_path):
128
135
  resp = access_guard.refusal(_verdict.denial, file_path, "read")
129
136
  if finalize is None:
130
137
  return resp
cli/tools/validate.py CHANGED
@@ -8,6 +8,7 @@ import subprocess
8
8
  import sys
9
9
  from pathlib import Path
10
10
 
11
+ from cli.tools import _grants
11
12
  from services import access_guard
12
13
 
13
14
 
@@ -169,7 +170,8 @@ async def _validate_one(file_path: str, svc) -> tuple:
169
170
  # Access Guard: read verdict (docs/access-guard.md §3) — the S1 refusal
170
171
  # becomes the batch line for this member; other members are still served.
171
172
  denial = access_guard.check(file_path, "read", svc.project_path)
172
- if denial:
173
+ if denial and not _grants.allow(svc, denial, tool="c3_validate",
174
+ op="read", path=file_path):
173
175
  return ("SKIP", access_guard.refusal(denial, file_path, "read"))
174
176
  full = Path(svc.project_path) / file_path
175
177
  if not full.exists():
@@ -250,7 +252,8 @@ async def _validate_single(file_path: str, svc, finalize) -> str:
250
252
  """Original single-file validation path."""
251
253
  # Access Guard: read verdict, checked before existence (R2 probe parity).
252
254
  denial = access_guard.check(file_path, "read", svc.project_path)
253
- if denial:
255
+ if denial and not _grants.allow(svc, denial, tool="c3_validate",
256
+ op="read", path=file_path):
254
257
  return finalize("c3_validate", {"file_path": file_path},
255
258
  access_guard.refusal(denial, file_path, "read"),
256
259
  "access-denied")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.71.0
3
+ Version: 2.74.0
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,6 +1,6 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=03fDyLvfzF832M7L5B2DgR8KMWXIObpbYxSC3LbhQ04,17229
3
- cli/c3.py,sha256=Jk3Gb08tbxOLGNOyuKQswhXy3ByZEiTpXjKPYcpY8hc,368519
3
+ cli/c3.py,sha256=esat1rA74wsB9dcct0pWbx1oHarGXQwB4xLzj0sqk2E,368519
4
4
  cli/docs.html,sha256=bNymSz7LatnWHjxSXL92QSUtGvB3jBzNHbOV-bRpAOo,142507
5
5
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
6
6
  cli/hook_access_guard.py,sha256=VOW6AJlSiBYg3H_O_EwRrjk8ynF1IqWCcrHrS443Noc,7256
@@ -66,30 +66,31 @@ cli/hub_ui/components/task_board.js,sha256=QiWKmj1NkhkloGlfzZj_tWd7Sgz3xSxwVLp8I
66
66
  cli/hub_ui/components/toasts.js,sha256=caj_x3npcM_wMv9E_xEde88jyfXI-jt8RrDQW_yXz5k,3376
67
67
  cli/hub_ui/components/topbar.js,sha256=UKzIBCkJ6j4tiBDClL5-D9ScXNEoTs2K6wO9tXy5qkY,4449
68
68
  cli/tools/__init__.py,sha256=HO6eVKzDm1KPqsKTHY6lfIsnFnv3C7Bd9o3Q_V88idg,127
69
+ cli/tools/_grants.py,sha256=6fnVm2blQ60j_0jIJFvDTtnXOdrlOy2zc39_s5VA-LY,2899
69
70
  cli/tools/_helpers.py,sha256=Yo_ol8bFT2R5ayjT54pXLVZhW_PgpBVoM8I7pDoomCI,7823
70
71
  cli/tools/agent.py,sha256=Fp9XLrOqjSP7_Ihq7VXjYXcGoXjw17-Hx3JMvoOVnhc,48105
71
72
  cli/tools/artifacts.py,sha256=LkSdjRz1TuYSFD1H_GSsxvhXOogaMt_JMbOnmkLOSUU,7784
72
73
  cli/tools/bitbucket.py,sha256=_hgORCW9-IF5xZJaznoONVfaTGKaX8VcU7ALrBeLhWo,27518
73
- cli/tools/compress.py,sha256=3mLrjYINhBJstkNFjEC3TV1Uif6IKSMycoGPb8VN59s,12670
74
+ cli/tools/compress.py,sha256=IW2I2duAwj1Z2NPG7znX6rDcG3f83r5RvCVpR0yinmY,12881
74
75
  cli/tools/credentials.py,sha256=nUsunY09zXYAX-D5oEPD6B2WaWicMLJDNjjxbdce4gE,10566
75
76
  cli/tools/delegate.py,sha256=KXy4wZhex9HKixj9HVNIGKsHJHJwiPi6LAP3GRkZoxs,59469
76
- cli/tools/edit.py,sha256=BqFe9konRKHUp1NUAZiBRyNVm4nGf5vsP2jaF5M5I7Q,26972
77
+ cli/tools/edit.py,sha256=90tC_UOvxETVRItxlEkOnL6gBW0D6X-qzkSGeLKfiOM,26874
77
78
  cli/tools/edits.py,sha256=8zM01TzLmjm7ULQlCmXOmitlJd84zQHVzE0z7UHJUdA,5520
78
79
  cli/tools/federate.py,sha256=wmC2QN7A6aay3cT7U9LDPYRCZKL1m_T6qFdZzpZmPx4,4650
79
- cli/tools/filter.py,sha256=99LNCzyky-JE9c9o-MixiQJSlWQMO7mWNERWmJgAVgI,12519
80
- cli/tools/impact.py,sha256=Nm9Zhee3ufhp6Wq1sRJ3iIn6hartlY4Qey0ds4Ag7iY,7048
80
+ cli/tools/filter.py,sha256=mwNabPe7bCvdsIGFsWc4E1HsfiRYx5ZevnxdsitJTAw,12702
81
+ cli/tools/impact.py,sha256=J6dvMrma1E2jGlhi3dIeQXhdwya5xljrJ92eiRuDaIE,7198
81
82
  cli/tools/jira.py,sha256=q85VyzzH_gFsAZaszf-exIAlMZVyJ1q-no8mhIMUn9E,17951
82
- cli/tools/locks.py,sha256=krhuTmJFFjspIF8ESHwLTRS3sp_WzglvOPFajslySpw,6076
83
+ cli/tools/locks.py,sha256=FvuNxToU-5ZE2m_C6fTrn1jKERoxR4cbtxNb_b8mXqA,5882
83
84
  cli/tools/memory.py,sha256=yDDRsEngeFcjb6nXUrhRZXuboasXfixJeyltKhDbZD4,24935
84
- cli/tools/override.py,sha256=TctImNJfHRqjyAFDLXpi-RbyznYFnvHCKAinjyCFYus,7980
85
+ cli/tools/override.py,sha256=2H0HO8tTN78LE-kklhc5KocNplqDcQbnyRLZAkntUbk,7877
85
86
  cli/tools/project.py,sha256=OT2SwzdcQ7Uqc5DHoHZfxcvLGD9K3wQ2dG9uly5cAHw,20406
86
- cli/tools/read.py,sha256=bCv29i3bYOMfmoiQ-Ahpn8rerigDDNfd_An_p5ItlIU,15578
87
+ cli/tools/read.py,sha256=sqTzn6FDaTQ3TFyP3J8fg61WemOsH-2oLIt19hmZXlI,16091
87
88
  cli/tools/search.py,sha256=cW0h5_yK7b1s3aqFKk3Z7nz4GSDhuJYDHdSXUH9uli0,17091
88
89
  cli/tools/session.py,sha256=ailQiRqXhi9NcBB3XS3Dv72GEkEZaYivB0bqDlhmgTA,5202
89
90
  cli/tools/shell.py,sha256=ZjBN_iID6oGSzjaZ19IAlFik4DP8M0JkD3nh4r0pRdE,26283
90
91
  cli/tools/status.py,sha256=TRbiHVaDcrH96m5p1dMtbBlwGxTax3DcUih4dLs9CEw,16158
91
92
  cli/tools/tasks.py,sha256=kS0K73c6D5aaKGtarGI8XAYUjzPWBmIQqlsGGg5meJ4,20697
92
- cli/tools/validate.py,sha256=jQc1aUkgY9_rb2uGl8UY1V_5w9xqmTAjfk11qAuWbwY,13581
93
+ cli/tools/validate.py,sha256=Y7-jMfelMeENJ3GExvC0qshfFmD6PE3aTfOJg8V_qtg,13847
93
94
  cli/ui/api.js,sha256=wSviW2AgkSUwLjjQCcgsLHuRYhE56Mkcr2q2xHNq08s,1815
94
95
  cli/ui/app.js,sha256=8QwyCxT_6nNqiFibKESA9vgW_s2bVWWKeHU4fFm-Szg,10169
95
96
  cli/ui/icons.js,sha256=VT5VVCQtWh_YTxCvf64YBgiwx3vRBBXkEALx8N10dwg,5224
@@ -110,7 +111,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
110
111
  cli/ui/components/settings.js,sha256=ATbAjBlVIwCNpxq7s191b49a_INQV38iwmySqtJLYwY,79066
111
112
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
112
113
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
113
- code_context_control-2.71.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
114
+ code_context_control-2.74.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
114
115
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
115
116
  core/config.py,sha256=YmkcZwedz_lfDM0ZuI_f4xpe98ixPLcSQFcTCHgRiRg,19206
116
117
  core/ide.py,sha256=V6VVMVsFdmmcsMyxikjQp7z9xa42CWiHKS-ya-MAcG4,6172
@@ -135,7 +136,7 @@ oracle/services/insight_engine.py,sha256=lwcdXtjt3CSZLqHVpXx6SG5gDhZYUKwXt4Z8dGy
135
136
  oracle/services/local_session.py,sha256=51iKUPTM2C4kuk-rnHpjvuc688ofD6AzjTMrd_RYw6E,5263
136
137
  oracle/services/memory_reader.py,sha256=rSEmoIXQu2JkolHJIQWpn0piPJpyuf1KZvW_9liQwkU,3727
137
138
  oracle/services/memory_writer.py,sha256=hpvYsxjhl293jEjGcRKfjG5njUaAOXKqbmJaUu1WdUU,6997
138
- oracle/services/mobile_api.py,sha256=69oQlEaJD1lI7es6GytK1AVKnxiyjOFL5J1lCzb-FRU,86109
139
+ oracle/services/mobile_api.py,sha256=9gyDvJ7vKZ1nVb2k8AbjcquIfmPXzPT1lGkWkFG0OdQ,91712
139
140
  oracle/services/project_scanner.py,sha256=gHpXihRZSu2gXCp4vvOWp5gMAsmfINE9ESiqVF1uV3A,5587
140
141
  oracle/services/review_agent.py,sha256=m_fzoHowpiibf0_iz3dF72LWSrwB_FbDd1am-SbeWXg,11273
141
142
  oracle/services/tool_executor.py,sha256=xtAkBWkclh_FwOgzprjGkyRIV8-A7Lc3bz0UFPShrv0,1113
@@ -160,7 +161,7 @@ oracle/ui/chat/send.js,sha256=k2qGN_asb7FTjcLa204Zjq4X_QWHIKHQnI33IEuxvaQ,11558
160
161
  oracle/ui/chat/stream_renderer.js,sha256=RZndI2mu5xbzZF-4NG9jeOqXTcmIqfZqxgkrBvTzWmk,29861
161
162
  oracle/ui/chat/toolbar.js,sha256=ZDexIIpGwsQ6XsY4Tyo082rJoofdD_JBTV97-mLQUVM,12027
162
163
  services/__init__.py,sha256=3Kn4cZweLm7at8wFdBdZ-Zwo8hHcnVIsmY5f29nzi2Y,116
163
- services/access_guard.py,sha256=sMm3Y7fBtGOadYAYdGJXaxHlsAxR7kRGO5iGIK4rKZk,51281
164
+ services/access_guard.py,sha256=RKvbW12aRLUwkPPssxmLWpSk7_z4DDeB8S7g1BNg2hc,52947
164
165
  services/access_telemetry.py,sha256=9pOCLS9LgDtM7Z4cCEyl7osxhj-m3wsPubcXolBKuV4,9568
165
166
  services/activity_log.py,sha256=PNGeEYlw1Aux-mRU4pKCQdbsSuzLvOg7MYiq-cb4n8g,4473
166
167
  services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
@@ -206,14 +207,15 @@ services/memory_grounder.py,sha256=vM7_n_Ezkkc3oGQ1MECUT9Bpt2K1fsNx2kWiumAW6Qc,1
206
207
  services/memory_queue.py,sha256=sSFh2oU_BLbv90jXhPbRWNIS2nlUi468-INw96GFIwU,3852
207
208
  services/memory_scorer.py,sha256=32wccyVFBgcNwKdjwj_7RDnPukxSWBng8ohsRPTpQMM,9497
208
209
  services/metrics.py,sha256=X4D5TgcQSFaFLbBoa1s3bAWf00DMMjq3IrOACrK-uDg,3289
209
- services/notifications.py,sha256=-yriAkWmISBRZY1JZznYyYl2FZnMeMlh9564faH99Dk,14281
210
+ services/notifications.py,sha256=DwwE07-BI5-9s7G00onsiIyy0XHjppDFjWZ0SESoftQ,15508
210
211
  services/ollama_bridge.py,sha256=16ukWS98H4wFulOphUJDuXhN61GCiay793XLoyxTU-s,17455
211
212
  services/ollama_client.py,sha256=UiC5Abca622-ac-4goCkAjwo7iUye9g9JGFMN6Ea08M,7983
212
213
  services/ollama_credentials.py,sha256=EQx0YljYRrRBJ2voALlVTv11UBjfw-A6N7k7EP7fUFE,2770
213
214
  services/output_filter.py,sha256=lknQHs38JlRtXU4Ogoru6sveNokSKBQPfZXhf-fE0xM,21736
214
215
  services/override_grants.py,sha256=ZONuzlzjJ1iljfphX27RmPKLjibxoSARQcAAjRxVUJE,20203
215
- services/override_policy.py,sha256=bDXxCf7miDmsl0AGAh1xBopiS1CAgE5352aCKJIUc4w,14714
216
- services/override_requests.py,sha256=m1GKapR-f6qTJaBhvNxDzq-W_XRxedC1-PXAoQpeob8,23490
216
+ services/override_policy.py,sha256=fbOCgNTidNy4LuALATWHp2LcAr1KllPXm2e5pEwSOE4,16326
217
+ services/override_requests.py,sha256=HttGRNf9DdrhhR5coORu5q8lMbmEABPqbOMyhCfe4Po,25106
218
+ services/override_wake.py,sha256=HjSOXQTDi0irgeMyrTqngL7xLcSOOIgYsVW9yxod4ps,10363
217
219
  services/parser.py,sha256=CPoIN1FmX7kK-55FnC3jvRy3NLjlnf1armTaD6o7GBg,55203
218
220
  services/project_manager.py,sha256=LaNWqBeVMx6wcB6iiOQyRWSWhS-JXzfDApJmHFmTCy8,36477
219
221
  services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
@@ -261,8 +263,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
261
263
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
262
264
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
263
265
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
264
- code_context_control-2.71.0.dist-info/METADATA,sha256=JuTLloJ5Se5rtnIAWk4-e37C8-RD2QnEWs42Zw5VM_U,25601
265
- code_context_control-2.71.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
266
- code_context_control-2.71.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
267
- code_context_control-2.71.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
268
- code_context_control-2.71.0.dist-info/RECORD,,
266
+ code_context_control-2.74.0.dist-info/METADATA,sha256=lg_WYK9ceV_WdNnzUCdBi_xz-FMsOicT8srl04sbDTE,25601
267
+ code_context_control-2.74.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
268
+ code_context_control-2.74.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
269
+ code_context_control-2.74.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
270
+ code_context_control-2.74.0.dist-info/RECORD,,
@@ -46,6 +46,7 @@ import hashlib
46
46
  import json
47
47
  import os
48
48
  import threading
49
+ import time
49
50
  from datetime import datetime, timezone
50
51
  from pathlib import Path
51
52
 
@@ -58,10 +59,13 @@ from oracle.services.tool_registry import _c3_version
58
59
 
59
60
  # 2: /info reports EFFECTIVE capabilities (filtered by the config switches)
60
61
  # rather than a static list, so a client can gate its UI instead of probing.
61
- API_VERSION = 2
62
+ # 3: /feed accepts `wait` (long-poll). Advertised as the `feed_wait`
63
+ # capability so a client can choose to hold a connection open instead of
64
+ # polling, rather than discovering support by timing a request.
65
+ API_VERSION = 3
62
66
 
63
67
  CAPABILITIES = [
64
- "feed", "projects", "health", "pm", "pm_events", "digest",
68
+ "feed", "feed_wait", "projects", "health", "pm", "pm_events", "digest",
65
69
  "notifications_ack",
66
70
  "credentials", "credentials_write",
67
71
  "access", "access_write",
@@ -90,6 +94,38 @@ _SOURCE_CAP = 1000
90
94
  _MAX_LIMIT = 200
91
95
  _DEFAULT_LIMIT = 50
92
96
 
97
+ # ── Long-poll (`/feed?wait=`) ─────────────────────────────
98
+ #
99
+ # WHY: the phone's only delivery paths were two pollers — every 15s while the
100
+ # app is open, and Android WorkManager's ~15-MINUTE floor once it is closed.
101
+ # An override request with a 10-minute TTL could therefore expire before the
102
+ # phone was ever told it existed (observed 2026-08-07). `wait` lets a client
103
+ # hold one request open and be answered the moment something lands, turning
104
+ # "up to 15 seconds" into "about a second" without spending a request every
105
+ # 15s to get it.
106
+ #
107
+ # It is NOT push, and nothing here should be read as a claim that it is: a
108
+ # process Android has frozen cannot hold a socket. This narrows the live
109
+ # window. FCM or a foreground service is what would close the closed-app one.
110
+ _MAX_WAIT_S = 30
111
+ # Re-scan cadence while holding. The scan is skipped unless an mtime moved,
112
+ # so this is a stat() per second, not a feed rebuild per second.
113
+ _WAIT_TICK_S = 1.0
114
+ # Each waiter parks a server thread for up to _MAX_WAIT_S, and the Oracle
115
+ # serves the desktop UI and discovery off the same pool. Past the cap `wait`
116
+ # degrades to an ordinary immediate answer rather than queueing behind other
117
+ # waiters. One phone needs one slot; four leaves room for the watch and a
118
+ # spare.
119
+ _MAX_WAITERS = 4
120
+ _waiters = threading.Semaphore(_MAX_WAITERS)
121
+
122
+ #: Files whose mtime means "the feed may have changed" — the same set
123
+ #: ``_last_activity`` reports on, kept literal in both places on purpose. Here
124
+ #: it is a correctness input: a file missing from this tuple is a wake that
125
+ #: never returns early.
126
+ _FEED_FILES = ("activity_log.jsonl", "edit_ledger.jsonl",
127
+ "notifications.jsonl", "pm/pm.json")
128
+
93
129
  bp = Blueprint("mobile", __name__, url_prefix="/api/mobile")
94
130
 
95
131
  # Wired by oracle_server at import time (configure) and startup (init_services).
@@ -489,6 +525,54 @@ def _feed_items_for(path: str, name: str, types: set, severities: set,
489
525
  return items
490
526
 
491
527
 
528
+ def _feed_mtime(targets: list) -> float:
529
+ """Newest mtime across every target's feed files. 0.0 when none exist."""
530
+ latest = 0.0
531
+ for t in targets:
532
+ c3 = Path(t["path"]) / ".c3"
533
+ for rel in _FEED_FILES:
534
+ try:
535
+ latest = max(latest, (c3 / rel).stat().st_mtime)
536
+ except OSError:
537
+ continue
538
+ return latest
539
+
540
+
541
+ def _hold_for_items(targets: list, collect, wait_s: int):
542
+ """Block up to *wait_s* for the feed to produce something.
543
+
544
+ Returns ``(items, waited_seconds)``. Rebuilding the feed every tick would
545
+ be a full history read per target, so the loop watches mtimes and only
546
+ pays for a rebuild when one moves. An mtime that moves without producing a
547
+ matching item — a severity this client filters out, say — re-baselines
548
+ instead of re-scanning on every subsequent tick.
549
+
550
+ Returns immediately when every waiter slot is taken. The caller then
551
+ behaves exactly as if ``wait`` had not been passed: a slower client, not a
552
+ wrong one.
553
+ """
554
+ started = time.monotonic()
555
+ if not _waiters.acquire(blocking=False):
556
+ return [], 0.0
557
+ try:
558
+ seen = _feed_mtime(targets)
559
+ deadline = started + wait_s
560
+ while True:
561
+ remaining = deadline - time.monotonic()
562
+ if remaining <= 0:
563
+ return [], time.monotonic() - started
564
+ time.sleep(min(_WAIT_TICK_S, remaining))
565
+ current = _feed_mtime(targets)
566
+ if current == seen:
567
+ continue
568
+ seen = current
569
+ found = collect()
570
+ if found:
571
+ return found, time.monotonic() - started
572
+ finally:
573
+ _waiters.release()
574
+
575
+
492
576
  @bp.route("/feed")
493
577
  def mobile_feed():
494
578
  """Merged cross-project feed, newest first.
@@ -496,11 +580,14 @@ def mobile_feed():
496
580
  Query: types (csv of notification|activity|edit|session_stat, default
497
581
  all), project (optional single project), severity (csv, notifications
498
582
  only), since (exclusive watermark — "only newer than"), before
499
- (exclusive pagination cursor), limit (default 50, max 200).
583
+ (exclusive pagination cursor), limit (default 50, max 200), wait
584
+ (seconds, 0-30 — hold the request open until something matches).
500
585
 
501
586
  Response: {items, next_cursor, truncated}. Cursor is the ts of the last
502
587
  returned item; identical-ts items can repeat across pages, so clients
503
- dedup by item id.
588
+ dedup by item id. A `wait` request also carries `waited_s`, and an empty
589
+ `items` after a full hold is the normal, expected answer — the client
590
+ reconnects with the same watermark.
504
591
  """
505
592
  if _scanner is None:
506
593
  return jsonify({"error": "not initialized"}), 500
@@ -523,6 +610,18 @@ def mobile_feed():
523
610
  except (TypeError, ValueError):
524
611
  limit = _DEFAULT_LIMIT
525
612
 
613
+ try:
614
+ wait_s = max(0, min(_MAX_WAIT_S, int(request.args.get("wait", 0))))
615
+ except (TypeError, ValueError):
616
+ wait_s = 0
617
+ # Holding open only makes sense against a watermark. Without `since` the
618
+ # first scan matches history and returns instantly, so a client that
619
+ # forgot it would silently get the old behaviour and never learn why its
620
+ # push felt slow; with `before` this is a pagination cursor, where waiting
621
+ # is meaningless.
622
+ if before or not since:
623
+ wait_s = 0
624
+
526
625
  project_arg = (request.args.get("project") or "").strip()
527
626
  if project_arg:
528
627
  resolved, err = _project_or_404(project_arg)
@@ -536,10 +635,17 @@ def mobile_feed():
536
635
  if p.get("has_c3") and p.get("path")
537
636
  and (Path(p["path"]) / ".c3").is_dir()]
538
637
 
539
- items: list[dict] = []
540
- for t in targets:
541
- items.extend(_feed_items_for(t["path"], t["name"], types, severities,
542
- since, before))
638
+ def collect() -> list:
639
+ out: list[dict] = []
640
+ for t in targets:
641
+ out.extend(_feed_items_for(t["path"], t["name"], types,
642
+ severities, since, before))
643
+ return out
644
+
645
+ items = collect()
646
+ waited = 0.0
647
+ if not items and wait_s:
648
+ items, waited = _hold_for_items(targets, collect, wait_s)
543
649
 
544
650
  items.sort(key=lambda i: i["ts"], reverse=True)
545
651
  truncated = len(items) > limit
@@ -548,6 +654,9 @@ def mobile_feed():
548
654
  "items": page,
549
655
  "next_cursor": page[-1]["ts"] if truncated and page else None,
550
656
  "truncated": truncated,
657
+ # Only when the client asked to hold, so the response shape is
658
+ # byte-identical for every client that did not.
659
+ **({"waited_s": round(waited, 2)} if wait_s else {}),
551
660
  })
552
661
 
553
662
 
@@ -1634,6 +1743,10 @@ def mobile_enforcement_set():
1634
1743
  # (e.g. `path_key`) cannot silently start crossing the network.
1635
1744
 
1636
1745
  #: §3.3 field order, verbatim. The mobile client reads these names.
1746
+ #: The one `override` key no remote surface may write. See the refusal in
1747
+ #: `mobile_overrides_policy_set` for why it is a 403 rather than a 400.
1748
+ _WAKE_KEY = "wake"
1749
+
1637
1750
  _OVERRIDE_FIELDS = (
1638
1751
  "id", "project_path", "session_id", "created_at", "expires_at", "status",
1639
1752
  "layer", "rule", "rule_class", "scope", "tool", "op", "path", "refusal",
@@ -1809,6 +1922,17 @@ def mobile_overrides_policy_set():
1809
1922
  section = data.get("override")
1810
1923
  if not isinstance(section, dict):
1811
1924
  return jsonify({"error": "body needs an 'override' object"}), 400
1925
+ if _WAKE_KEY in section:
1926
+ # `wake` is an argv this machine will execute. Everything else on this
1927
+ # route widens what a tap can approve; this one would decide what runs
1928
+ # when it does. A bearer token from a phone is authentication, not
1929
+ # physical presence, so it stays a desktop edit to a human-owned file.
1930
+ return jsonify({
1931
+ "error": "'wake' cannot be set from the phone — it names a "
1932
+ "command this machine runs. Edit .c3/config.json on the "
1933
+ "desktop.",
1934
+ "key": _WAKE_KEY,
1935
+ }), 403
1812
1936
  unknown = sorted(set(section) - set(opol.DEFAULTS))
1813
1937
  if unknown:
1814
1938
  # §3.1: unknown keys are a hard error, never a silent no-op.
services/access_guard.py CHANGED
@@ -709,11 +709,12 @@ def _override_offer(denial: Denial, path, operation: str, tool: str) -> str:
709
709
  that can never be escalated: an agent must not learn from a refusal that
710
710
  a request surface exists for the credential vault (§6).
711
711
 
712
- Restricted to the hook surface on purpose. The offer promises that a human
713
- 'yes' makes the retry work, and today only the PreToolUse gates consult
714
- grants — the ``c3_*`` content surfaces do not (see §13, phase P2a). An
715
- offer on a surface that would still refuse after approval is worse than
716
- no offer.
712
+ Restricted to the hook surface on purpose — see ``refusal``. The rule was
713
+ written when only the PreToolUse gates consulted grants; P2a taught the
714
+ ``c3_*`` content surfaces to consult them too, so extending the offer to
715
+ the MCP surface is now defensible. It is deliberately NOT done here: that
716
+ is a separate, wider change (every c3_* refusal grows a line) and belongs
717
+ in its own review, not smuggled in beside a fix for the branch ordering.
717
718
  """
718
719
  try:
719
720
  from services import override_policy as _op # noqa: PLC0415 — cycle
@@ -738,7 +739,26 @@ def refusal(denial: Denial, path, operation: str, *, surface: str = "mcp",
738
739
  the agent it may ASK (docs/override-requests.md §6). The pinned strings
739
740
  above are unchanged: the append is absent unless the project opted in,
740
741
  which is off by default.
742
+
743
+ The append happens HERE, once, for every kind — not inside a branch. It
744
+ used to live at the tail of the deny branch, which sits below the mask and
745
+ read-only early returns, so those two kinds could never carry an offer on
746
+ any surface even though `rule_class_for_denial` marks both escalatable.
747
+ `access_readonly` was the only layer anyone had turned on, so in practice
748
+ the invitation half of Override Requests had never fired once.
741
749
  """
750
+ body = _refusal_body(denial, path, operation, surface=surface,
751
+ tool=tool, project=project)
752
+ # Hook only, still: the offer promises that a 'yes' makes the retry work,
753
+ # and the proxy surface answers for a DIFFERENT project's policy.
754
+ if surface == "hook":
755
+ return body + _override_offer(denial, path, operation, tool)
756
+ return body
757
+
758
+
759
+ def _refusal_body(denial: Denial, path, operation: str, *, surface: str,
760
+ tool: str, project: str) -> str:
761
+ """The pinned string itself, with no override append."""
742
762
  p, glob, scope = _cap(path), denial.rule, denial.scope
743
763
  if denial.kind == _KIND_MASK:
744
764
  if operation in ("write", "create", "delete"):
@@ -762,12 +782,20 @@ def refusal(denial: Denial, path, operation: str, *, surface: str = "mcp",
762
782
  "`c3 access list` or the Access tab."
763
783
  )
764
784
  if denial.kind == _KIND_READ_ONLY:
785
+ # `{operation}`, not a hardcoded "write". A read-only rule blocks the
786
+ # whole write class, and for a file that does not exist yet the tools
787
+ # call that operation `create`. Saying "write" told the agent to ask
788
+ # the user to approve `op='write'`, the approval was minted for
789
+ # `write`, and the grant matcher — which compares op exactly — then
790
+ # refused the `create` the tool actually attempted. A real approval
791
+ # was spent and silently discarded (§4 near_miss).
765
792
  return (
766
- f"{TAG_READ_ONLY} write denied for {p} by Access Guard rule "
793
+ f"{TAG_READ_ONLY} {operation} denied for {p} by Access Guard rule "
767
794
  f"'{glob}' ({scope} scope). The effective policy is read-only; "
768
- "reads are evaluated separately. Do not retry the write. Mark "
769
- "the affected step blocked and continue with unaffected files; "
770
- "report the skip. Rules: `c3 access list` or the Access tab."
795
+ f"reads are evaluated separately. Do not retry the {operation}. "
796
+ "Mark the affected step blocked and continue with unaffected "
797
+ "files; report the skip. Rules: `c3 access list` or the Access "
798
+ "tab."
771
799
  )
772
800
  if surface == "hook":
773
801
  return (
@@ -776,7 +804,7 @@ def refusal(denial: Denial, path, operation: str, *, surface: str = "mcp",
776
804
  "decision, not a transient error — do not retry through another "
777
805
  "tool or the shell. Mark the affected step blocked and continue "
778
806
  "with unaffected files. Rules: `c3 access list`."
779
- ) + _override_offer(denial, path, operation, tool)
807
+ )
780
808
  if surface == "proxy":
781
809
  return (
782
810
  f"{TAG_DENIED} {operation} denied for {p} through project "
services/notifications.py CHANGED
@@ -43,7 +43,8 @@ class NotificationStore:
43
43
  self._collapsed = False
44
44
 
45
45
  def add(self, agent: str, severity: str, title: str, message: str,
46
- ai_enhanced: bool = False, replace_if_unacked: bool = False) -> dict | None:
46
+ ai_enhanced: bool = False, replace_if_unacked: bool = False,
47
+ kind: str = "", ref_id: str = "") -> dict | None:
47
48
  """Append a notification. Dedup: an identical (agent, title, message)
48
49
  that is still unacknowledged collapses into the existing record — its
49
50
  ``count`` is bumped and ``last_seen`` refreshed instead of appending a
@@ -54,6 +55,13 @@ class NotificationStore:
54
55
  already exists, update its message/severity in-place (bumping
55
56
  count/last_seen) instead of appending a new entry. Use for
56
57
  high-frequency agents (budget, index, key-file drift) to prevent pile-up.
58
+ kind: machine-readable class for clients that ROUTE on a tap rather
59
+ than parse the title — 'override' for an approval request. Omitted
60
+ from the record when empty, so every existing producer and every
61
+ stored line is unchanged.
62
+ ref_id: the id of the thing the notification is ABOUT (e.g. the
63
+ override request), which is not the notification's own ``id``.
64
+ A client needs both: ``id`` to acknowledge, ``ref_id`` to navigate.
57
65
  Returns the entry if written/updated, None if deduped.
58
66
  """
59
67
  with self._lock:
@@ -75,6 +83,13 @@ class NotificationStore:
75
83
  existing["last_seen"] = now.isoformat()
76
84
  existing["count"] = _entry_count(existing) + 1
77
85
  existing["ai_enhanced"] = ai_enhanced
86
+ # Refresh the routing fields too: an in-place update
87
+ # keeps the record but the thing it points AT may have
88
+ # changed (a new request id under the same title).
89
+ if kind:
90
+ existing["kind"] = kind
91
+ if ref_id:
92
+ existing["ref_id"] = ref_id
78
93
  self._write_all(entries)
79
94
  return existing
80
95
  # Same notification still pending — collapse into the
@@ -108,6 +123,12 @@ class NotificationStore:
108
123
  "acknowledged": False,
109
124
  "ai_enhanced": ai_enhanced,
110
125
  }
126
+ # Additive only: absent keys rather than empty ones, so a client
127
+ # can test presence and old lines stay byte-identical in shape.
128
+ if kind:
129
+ entry["kind"] = kind
130
+ if ref_id:
131
+ entry["ref_id"] = ref_id
111
132
  with open(self._file, "a", encoding="utf-8") as f:
112
133
  f.write(json.dumps(entry) + "\n")
113
134
  return entry
@@ -93,6 +93,11 @@ DEFAULTS = {
93
93
  "max_requests_per_hour": 20,
94
94
  "notify_severity": "critical",
95
95
  "allow_session_grants": False,
96
+ # The command run when a request is DECIDED, so the asking agent hears
97
+ # about it (services/override_wake.py). None = nobody is listening, which
98
+ # is the pre-2.73 behaviour and the reason a grant could expire unused
99
+ # while the user assumed their tap had done something.
100
+ "wake": None,
96
101
  }
97
102
 
98
103
  _VALID_KEYS = frozenset(DEFAULTS)
@@ -193,6 +198,7 @@ class OverridePolicy:
193
198
  max_requests_per_hour: int = 20
194
199
  notify_severity: str = "critical"
195
200
  allow_session_grants: bool = False
201
+ wake: dict | None = None
196
202
  warnings: tuple = ()
197
203
  corrupt_scopes: tuple = ()
198
204
 
@@ -221,6 +227,11 @@ class OverridePolicy:
221
227
  "max_requests_per_hour": self.max_requests_per_hour,
222
228
  "notify_severity": self.notify_severity,
223
229
  "allow_session_grants": self.allow_session_grants,
230
+ # The spec itself never crosses the wire. It is an argv the box
231
+ # will run, and it can carry a conversation id or a path that says
232
+ # more about this machine than a policy screen needs to. A phone
233
+ # only needs to know whether anything is listening.
234
+ "wake_configured": bool(self.wake),
224
235
  "warnings": list(self.warnings),
225
236
  "corrupt_scopes": list(self.corrupt_scopes),
226
237
  }
@@ -255,6 +266,18 @@ def _validate(section: dict):
255
266
  return _CORRUPT
256
267
  if any(not isinstance(v, bool) for v in layers.values()):
257
268
  return _CORRUPT
269
+ if "wake" in section:
270
+ # Lazy import: override_wake reads a resolved policy back out of this
271
+ # module on the fire path, and a module-level import would close that
272
+ # loop. A validator we cannot even load reads as corrupt, not as
273
+ # "probably fine" — this key names a command the machine will run.
274
+ try:
275
+ from services import override_wake as owake # noqa: PLC0415
276
+ valid = owake.validate_spec(section["wake"])
277
+ except Exception:
278
+ return _CORRUPT
279
+ if not valid:
280
+ return _CORRUPT
258
281
  return section
259
282
 
260
283
 
@@ -350,6 +373,14 @@ def resolve(project_path: str = ".") -> OverridePolicy:
350
373
  if key in s:
351
374
  values[key] = s[key]
352
375
 
376
+ # `wake` is not a permission, so it has no tightening direction to merge
377
+ # along — it is "who to tell", and the nearest scope is the one that knows.
378
+ # Project last, so a project's own wake replaces the machine-wide default
379
+ # rather than firing both.
380
+ for s in sections:
381
+ if "wake" in s:
382
+ values["wake"] = s["wake"]
383
+
353
384
  return OverridePolicy(warnings=tuple(warnings), **values)
354
385
 
355
386
 
@@ -420,6 +420,12 @@ def _notify(project_path: str, row: dict, policy) -> None:
420
420
  f"{Path(row['path']).name}?",
421
421
  message=(f"Blocked by rule {row['rule']} ({row['rule_class']}). "
422
422
  f"Approve once: c3 override approve {row['id']}"),
423
+ # A phone routes on these, not on the title. Without them a tap
424
+ # opens the app and drops the user wherever they were last —
425
+ # which, for the one notification that exists to be answered, is
426
+ # the same as not delivering it (§9).
427
+ kind="override",
428
+ ref_id=row["id"],
423
429
  )
424
430
  except Exception:
425
431
  pass # a missing notification must never fail the request
@@ -460,6 +466,22 @@ def _notify_decision(project_path: str, row: dict, grant=None) -> None:
460
466
  pass # the decision already happened; a missing feed line never undoes it
461
467
 
462
468
 
469
+ def _wake(project_path: str, row: dict, grant=None, policy=None) -> dict:
470
+ """Tell the asking session a human answered (§7.1).
471
+
472
+ The notification feed tells the *user* what they decided. Nothing told the
473
+ *agent*, which is how an approved grant expired unused on 2026-08-08 with
474
+ everyone believing the tap had worked. Failure here is swallowed on
475
+ purpose: the decision is already on disk and the agent's ``action='status'``
476
+ path still works — a wake is a shortcut past waiting, not the mechanism.
477
+ """
478
+ try:
479
+ from services import override_wake as owake # noqa: PLC0415
480
+ return owake.fire(project_path, row, grant, policy=policy)
481
+ except Exception as exc:
482
+ return {"fired": False, "reason": f"wake unavailable: {exc}"}
483
+
484
+
463
485
  def withdraw(request_id: str, session_id: str) -> dict:
464
486
  """An agent cancelling its OWN pending request (e.g. it found another way)."""
465
487
  rows = load()
@@ -526,6 +548,10 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
526
548
  "decided_by": decided_by, "muted": bool(mute),
527
549
  })
528
550
  _notify_decision(project_path, row, grant=None)
551
+ # A denial is news too: without it the agent sits in `wait` until the
552
+ # request lapses, then reports a timeout for a question that was
553
+ # answered in two seconds.
554
+ row["wake"] = _wake(project_path, row)
529
555
  return row
530
556
 
531
557
  policy = op_policy.resolve(project_path)
@@ -569,4 +595,8 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
569
595
  row["grant_mode"] = mode
570
596
  _save(rows)
571
597
  _notify_decision(project_path, row, grant=grant)
598
+ # AFTER _save: the woken agent's very first move is to retry the blocked
599
+ # call, which reads this store. Waking before persisting would race the
600
+ # agent against the decision it was told about.
601
+ row["wake"] = _wake(project_path, row, grant, policy=policy)
572
602
  return row
@@ -0,0 +1,241 @@
1
+ """Wake the asking session when a human decides its override request.
2
+
3
+ WHY THIS EXISTS — a measured failure, not a hypothetical
4
+ --------------------------------------------------------
5
+ 2026-08-08, live end-to-end run. An agent asked for an override at 00:36Z,
6
+ ended its turn, and the user approved from the phone at 00:42:32Z. The grant
7
+ was minted correctly and expired unused at 00:57:32Z, because *nothing told
8
+ the agent*. Before this module there were exactly two ways an agent could
9
+ learn its request had been answered: block in ``c3_override(action='wait')``
10
+ — capped at 180s per call, and useless once the turn ends — or call
11
+ ``action='status'`` later, which requires something to have already woken it.
12
+ A decision was a write to disk that nobody was listening for. Grants are
13
+ capped at 900s, so an idle agent misses the whole window by construction.
14
+
15
+ WHAT IT DOES
16
+ ------------
17
+ On every decision (approve *and* deny), run one configured command. That is
18
+ the whole feature. The command is how the local orchestrator pokes whatever
19
+ runs the agent — a chat message that re-invokes it, a queue write, a webhook
20
+ via ``curl``. C3 does not know or care which; wiring an agent runtime in here
21
+ would make this file grow a backend per orchestrator and rot.
22
+
23
+ THREE THINGS THAT ARE DELIBERATE
24
+ --------------------------------
25
+ **argv, never a shell string.** ``command`` must be a list. There is no
26
+ ``shell=True`` anywhere in this file and no string form to fall back to, so a
27
+ placeholder carrying a quote or a semicolon is an argument, not syntax.
28
+ Placeholders are substituted per-element after the list is fixed, so no
29
+ substitution can ever add an argument.
30
+
31
+ **Config-only, and only from a file a human owns.** The spec is read from the
32
+ ``override.wake`` section of ``.c3/config.json``. Agents cannot write that
33
+ file — the builtin ``read_only **/.c3/**`` rule denies it — and
34
+ ``override_policy.forbidden_target`` refuses to let any grant cover it, so
35
+ this cannot be self-approved either. The mobile policy route rejects the
36
+ ``wake`` key outright (``mobile_api._WAKE_KEY``): a bearer token is not
37
+ permission to choose what command this machine runs.
38
+
39
+ **Synchronous, with a short timeout.** Backgrounding it looks kinder to the
40
+ caller and silently loses the wake: ``c3 override approve`` exits the instant
41
+ ``decide()`` returns, taking any daemon thread with it. So the decision waits
42
+ — bounded by ``timeout_s`` (default 10, hard max 60) — and the wake command's
43
+ job is to hand off fast, not to do work.
44
+
45
+ A wake that fails never unwinds a decision. The approval already happened;
46
+ the agent falling back to ``action='status'`` is a slower path, not a wrong
47
+ one. Every outcome, success or failure, lands in ``.c3/overrides.jsonl``.
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import subprocess
52
+ from pathlib import Path
53
+
54
+ from services import override_grants as og
55
+
56
+ #: Lifecycle events, appended to the same audit the rest of the feature uses.
57
+ EV_WOKE = "woke"
58
+ EV_WAKE_FAILED = "wake_failed"
59
+
60
+ DEFAULT_TIMEOUT_S = 10
61
+ MAX_TIMEOUT_S = 60
62
+
63
+ #: Statuses a wake may be requested for. ``expired`` is absent on purpose:
64
+ #: nothing decided it, so there is no news to deliver.
65
+ VALID_ON = ("approved", "denied")
66
+
67
+ _VALID_KEYS = frozenset({"command", "cwd", "timeout_s", "on"})
68
+
69
+
70
+ def validate_spec(spec) -> bool:
71
+ """True iff *spec* is a usable ``override.wake`` section.
72
+
73
+ Called from ``override_policy._validate``, which treats the whole
74
+ ``override`` section as corrupt when this returns False — the same
75
+ fail-closed reading every other key gets. A wake we cannot parse must not
76
+ degrade into "no wake, carry on quietly": that is the bug this feature
77
+ exists to fix, reintroduced as a typo.
78
+ """
79
+ if spec is None:
80
+ return True
81
+ if not isinstance(spec, dict) or set(spec) - _VALID_KEYS:
82
+ return False
83
+ command = spec.get("command")
84
+ if not isinstance(command, list) or not command:
85
+ return False
86
+ if any(not isinstance(a, str) or not a for a in command):
87
+ return False
88
+ if "cwd" in spec and not isinstance(spec["cwd"], str):
89
+ return False
90
+ if "timeout_s" in spec:
91
+ t = spec["timeout_s"]
92
+ if isinstance(t, bool) or not isinstance(t, int) or t < 1:
93
+ return False
94
+ on = spec.get("on")
95
+ if on is not None:
96
+ if not isinstance(on, list) or not on:
97
+ return False
98
+ if set(on) - set(VALID_ON):
99
+ return False
100
+ return True
101
+
102
+
103
+ def wake_message(row: dict, grant=None) -> str:
104
+ """The line the woken agent reads. Written for a machine, not a human.
105
+
106
+ It names the decision, the identifiers needed to act on it, and — the part
107
+ that matters — exactly one next step. An agent woken with "something
108
+ happened" burns a turn rediscovering what; an agent woken with "retry the
109
+ same call once" does the thing the user tapped approve to make happen.
110
+ """
111
+ name = Path(str(row.get("path", ""))).name
112
+ call = f"{row.get('tool', '')} {row.get('op', '')} on {name}"
113
+ rid = row.get("id", "")
114
+ if row.get("status") == "approved":
115
+ g = grant or {}
116
+ return (
117
+ f"[c3-override] APPROVED {rid} — {call}. "
118
+ f"Grant {g.get('id', '')}, {g.get('uses_remaining', '?')} use(s), "
119
+ f"expires {g.get('expires_at', '')}. "
120
+ f"Retry the SAME call once, now, then report the outcome. "
121
+ f"The grant is single-use and session-bound; it dies unused if you wait."
122
+ )
123
+ muted = " The user also muted it: do not ask again this session." \
124
+ if row.get("muted") else ""
125
+ return (
126
+ f"[c3-override] DENIED {rid} — {call}."
127
+ f"{muted} Do not retry and do not re-ask: mark the step blocked and "
128
+ f"tell the user what you needed it for."
129
+ )
130
+
131
+
132
+ def _fields(project_path: str, row: dict, grant=None) -> dict:
133
+ g = grant or {}
134
+ return {
135
+ "request_id": str(row.get("id", "")),
136
+ "session_id": str(row.get("session_id", "")),
137
+ "status": str(row.get("status", "")),
138
+ "decided_by": str(row.get("decided_by", "")),
139
+ "tool": str(row.get("tool", "")),
140
+ "op": str(row.get("op", "")),
141
+ "path": str(row.get("path", "")),
142
+ "path_key": str(row.get("path_key", "")),
143
+ "rule": str(row.get("rule", "")),
144
+ "rule_class": str(row.get("rule_class", "")),
145
+ "layer": str(row.get("layer", "")),
146
+ "grant_id": str(g.get("id", "")),
147
+ "project": str(project_path),
148
+ "project_name": Path(str(project_path)).name,
149
+ "message": wake_message(row, grant),
150
+ }
151
+
152
+
153
+ def _subst(arg: str, fields: dict) -> str:
154
+ """``{name}`` → value, by explicit replace.
155
+
156
+ Not ``str.format``: the message and the rule glob both routinely contain
157
+ braces, and a format call would raise KeyError on ``{'a': 1}`` in a
158
+ justification — turning a wake into a silent no-op for the one request
159
+ whose refusal quoted a dict.
160
+ """
161
+ out = arg
162
+ for key, value in fields.items():
163
+ token = "{" + key + "}"
164
+ if token in out:
165
+ out = out.replace(token, value)
166
+ return out
167
+
168
+
169
+ def fire(project_path: str, row: dict, grant=None, *, policy=None) -> dict:
170
+ """Run the configured wake command. Returns a small result dict.
171
+
172
+ ``{"fired": False, "reason": ...}`` when there is nothing to do — no spec,
173
+ or a status this spec does not subscribe to. Never raises.
174
+ """
175
+ try:
176
+ spec = getattr(policy, "wake", None)
177
+ if spec is None:
178
+ from services import override_policy as opol # noqa: PLC0415
179
+ spec = getattr(opol.resolve(str(project_path)), "wake", None)
180
+ if not spec:
181
+ return {"fired": False, "reason": "no wake configured"}
182
+ if not validate_spec(spec):
183
+ return {"fired": False, "reason": "invalid wake spec"}
184
+
185
+ status = str(row.get("status", ""))
186
+ on = spec.get("on") or list(VALID_ON)
187
+ if status not in on:
188
+ return {"fired": False, "reason": f"status {status!r} not in on={on}"}
189
+
190
+ fields = _fields(project_path, row, grant)
191
+ argv = [_subst(a, fields) for a in spec["command"]]
192
+ timeout = min(int(spec.get("timeout_s", DEFAULT_TIMEOUT_S)),
193
+ MAX_TIMEOUT_S)
194
+ cwd = spec.get("cwd") or str(project_path)
195
+ if not Path(cwd).is_dir():
196
+ og.audit(project_path, EV_WAKE_FAILED, {
197
+ "request_id": fields["request_id"], "reason": "cwd missing",
198
+ "cwd": cwd,
199
+ })
200
+ return {"fired": False, "reason": f"cwd does not exist: {cwd}"}
201
+ except Exception as exc: # config reading must never break a decision
202
+ return {"fired": False, "reason": f"wake setup failed: {exc}"}
203
+
204
+ try:
205
+ proc = subprocess.run( # noqa: S603 — argv list, shell=False, from a
206
+ argv, # human-owned config file (see module docstring)
207
+ cwd=cwd,
208
+ timeout=timeout,
209
+ stdout=subprocess.DEVNULL,
210
+ stderr=subprocess.PIPE,
211
+ shell=False,
212
+ )
213
+ except subprocess.TimeoutExpired:
214
+ og.audit(project_path, EV_WAKE_FAILED, {
215
+ "request_id": fields["request_id"],
216
+ "session_id": fields["session_id"],
217
+ "reason": "timeout", "timeout_s": timeout, "argv0": argv[0],
218
+ })
219
+ return {"fired": True, "ok": False, "reason": f"timed out after {timeout}s"}
220
+ except Exception as exc:
221
+ og.audit(project_path, EV_WAKE_FAILED, {
222
+ "request_id": fields["request_id"],
223
+ "session_id": fields["session_id"],
224
+ "reason": type(exc).__name__, "detail": str(exc)[:200],
225
+ "argv0": argv[0],
226
+ })
227
+ return {"fired": True, "ok": False, "reason": str(exc)}
228
+
229
+ ok = proc.returncode == 0
230
+ og.audit(project_path, EV_WOKE if ok else EV_WAKE_FAILED, {
231
+ "request_id": fields["request_id"],
232
+ "session_id": fields["session_id"],
233
+ "status": status,
234
+ "exit_code": proc.returncode,
235
+ # argv0 only. The rest can carry a conversation id or a token-shaped
236
+ # argument, and the audit log is read by more eyes than the config is.
237
+ "argv0": argv[0],
238
+ **({"stderr": (proc.stderr or b"").decode("utf-8", "replace")[:300]}
239
+ if not ok else {}),
240
+ })
241
+ return {"fired": True, "ok": ok, "exit_code": proc.returncode}