code-context-control 2.70.0__py3-none-any.whl → 2.71.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.70.0"
95
+ __version__ = "2.71.0"
96
96
 
97
97
 
98
98
  def _compress_file_cli(compressor, path, mode="smart", **kw):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.70.0
3
+ Version: 2.71.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=RHoNXlhdwdnlUA6T21legFhDcTHQXDjk5QNStEgXPho,368519
3
+ cli/c3.py,sha256=Jk3Gb08tbxOLGNOyuKQswhXy3ByZEiTpXjKPYcpY8hc,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
@@ -110,14 +110,14 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
110
110
  cli/ui/components/settings.js,sha256=ATbAjBlVIwCNpxq7s191b49a_INQV38iwmySqtJLYwY,79066
111
111
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
112
112
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
113
- code_context_control-2.70.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
113
+ code_context_control-2.71.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
114
114
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
115
115
  core/config.py,sha256=YmkcZwedz_lfDM0ZuI_f4xpe98ixPLcSQFcTCHgRiRg,19206
116
116
  core/ide.py,sha256=V6VVMVsFdmmcsMyxikjQp7z9xa42CWiHKS-ya-MAcG4,6172
117
117
  core/mcp_toml.py,sha256=wfc7c6X7lIW5YpdWFnRK0qQ0M46v2t8j6WzcqfTKhaE,5627
118
118
  core/web_security.py,sha256=HwgK5aBATfvRIeO_4gWgzK77gKwGuzVlmiuNGm4gGxI,7884
119
119
  oracle/__init__.py,sha256=-OTD7Jh4mUMA4QgPGthPLWXttgZLpkIPhGQ87ZfHBx0,63
120
- oracle/config.py,sha256=hdN2zKZT1Fhn7HzE19GNcmZ7HwcmS6-xrydaU2mQY5A,5666
120
+ oracle/config.py,sha256=F2m9GfgJbpkmXEecglWF3GYeHtPSVDY6HOXQpiMJ3B8,6324
121
121
  oracle/mcp_oracle.py,sha256=B5B-qSxi37outsuRHn1Ns6Gj8e7W593rmL34LMzuE8M,8980
122
122
  oracle/oracle_server.py,sha256=oRGODRVWvdBo3KetTXnkrDMzkuwE4UdKIveW43DNmJ8,47710
123
123
  oracle/oracle_ui.html,sha256=QZ9jpB7IdDqnP2-e6Q9t1PPguSFHWwB1kf23wT7KsdI,70344
@@ -135,7 +135,7 @@ oracle/services/insight_engine.py,sha256=lwcdXtjt3CSZLqHVpXx6SG5gDhZYUKwXt4Z8dGy
135
135
  oracle/services/local_session.py,sha256=51iKUPTM2C4kuk-rnHpjvuc688ofD6AzjTMrd_RYw6E,5263
136
136
  oracle/services/memory_reader.py,sha256=rSEmoIXQu2JkolHJIQWpn0piPJpyuf1KZvW_9liQwkU,3727
137
137
  oracle/services/memory_writer.py,sha256=hpvYsxjhl293jEjGcRKfjG5njUaAOXKqbmJaUu1WdUU,6997
138
- oracle/services/mobile_api.py,sha256=GK4puRmO6BrojGPA9OKX2IjiAGTNynWtDb9ztZnxIWk,65909
138
+ oracle/services/mobile_api.py,sha256=69oQlEaJD1lI7es6GytK1AVKnxiyjOFL5J1lCzb-FRU,86109
139
139
  oracle/services/project_scanner.py,sha256=gHpXihRZSu2gXCp4vvOWp5gMAsmfINE9ESiqVF1uV3A,5587
140
140
  oracle/services/review_agent.py,sha256=m_fzoHowpiibf0_iz3dF72LWSrwB_FbDd1am-SbeWXg,11273
141
141
  oracle/services/tool_executor.py,sha256=xtAkBWkclh_FwOgzprjGkyRIV8-A7Lc3bz0UFPShrv0,1113
@@ -213,7 +213,7 @@ services/ollama_credentials.py,sha256=EQx0YljYRrRBJ2voALlVTv11UBjfw-A6N7k7EP7fUF
213
213
  services/output_filter.py,sha256=lknQHs38JlRtXU4Ogoru6sveNokSKBQPfZXhf-fE0xM,21736
214
214
  services/override_grants.py,sha256=ZONuzlzjJ1iljfphX27RmPKLjibxoSARQcAAjRxVUJE,20203
215
215
  services/override_policy.py,sha256=bDXxCf7miDmsl0AGAh1xBopiS1CAgE5352aCKJIUc4w,14714
216
- services/override_requests.py,sha256=AWmIhaQXa8gcUkDClT2hryKx--C5NsuYlgwaOUYklOg,14872
216
+ services/override_requests.py,sha256=m1GKapR-f6qTJaBhvNxDzq-W_XRxedC1-PXAoQpeob8,23490
217
217
  services/parser.py,sha256=CPoIN1FmX7kK-55FnC3jvRy3NLjlnf1armTaD6o7GBg,55203
218
218
  services/project_manager.py,sha256=LaNWqBeVMx6wcB6iiOQyRWSWhS-JXzfDApJmHFmTCy8,36477
219
219
  services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
@@ -261,8 +261,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
261
261
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
262
262
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
263
263
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
264
- code_context_control-2.70.0.dist-info/METADATA,sha256=Y76AzLKHzMFVdG6cNoBRpCXSO4HO4x3kNDps4Sa-tDw,25601
265
- code_context_control-2.70.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
266
- code_context_control-2.70.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
267
- code_context_control-2.70.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
268
- code_context_control-2.70.0.dist-info/RECORD,,
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,,
oracle/config.py CHANGED
@@ -37,6 +37,15 @@ DEFAULTS = {
37
37
  # A global-scope access rule applies to EVERY project on this machine —
38
38
  # blast radius beyond the projects the token can even enumerate.
39
39
  "mobile_access_global_scope": False,
40
+ # ── Override Requests (docs/override-requests.md §3.2) ──
41
+ # The approval inbox. `override` serves the read routes (list / one /
42
+ # policy); `override_write` is what turns a phone tap into a real grant,
43
+ # so it is the switch that matters. Both default ON to match the access
44
+ # pair — the feature is already off by default one layer down, in the
45
+ # PROJECT policy (`override.enabled: false`, every layer false), which is
46
+ # what decides whether a request can exist at all.
47
+ "mobile_override_enabled": True, # read: request list / one / policy
48
+ "mobile_override_write": True, # decide / mute / policy edit
40
49
  # Second, tighter budget for security mutations, on top of the shared
41
50
  # api_rate_limit_per_min bucket. 0 = share the main bucket only.
42
51
  "mobile_security_rate_limit_per_min": 12,
@@ -66,6 +66,7 @@ CAPABILITIES = [
66
66
  "credentials", "credentials_write",
67
67
  "access", "access_write",
68
68
  "enforcement", "enforcement_write",
69
+ "override", "override_write",
69
70
  ]
70
71
 
71
72
  # capability -> config key that gates it. Absent from this map = always on.
@@ -76,6 +77,8 @@ _CAPABILITY_SWITCHES = {
76
77
  "access_write": "mobile_access_write",
77
78
  "enforcement": "mobile_access_enabled",
78
79
  "enforcement_write": "mobile_access_write",
80
+ "override": "mobile_override_enabled",
81
+ "override_write": "mobile_override_write",
79
82
  }
80
83
 
81
84
  FEED_TYPES = ("notification", "activity", "edit", "session_stat")
@@ -1616,3 +1619,469 @@ def mobile_enforcement_set():
1616
1619
  pass
1617
1620
  _gw_audit("mobile.enforcement.set_mode", {"mode": result["mode"]})
1618
1621
  return jsonify(result)
1622
+
1623
+
1624
+ # ── Override Requests (docs/override-requests.md §8) ──────
1625
+ # The approval inbox: the agent asks, the phone answers, the answer is a
1626
+ # narrow time-boxed grant rather than a policy change. These routes are
1627
+ # deliberately THIN — every decision, refusal and grant-mint lives in
1628
+ # services/override_requests.py so the CLI (`c3 override`) and this surface
1629
+ # cannot drift apart on the one check that matters (the typed confirmation).
1630
+ #
1631
+ # The wire contract is frozen in §3.3 and re-asserted by
1632
+ # tests/test_mobile_override_routes.py: the request row is serialised through
1633
+ # an explicit allowlist, not `dict(row)`, so an internal field added later
1634
+ # (e.g. `path_key`) cannot silently start crossing the network.
1635
+
1636
+ #: §3.3 field order, verbatim. The mobile client reads these names.
1637
+ _OVERRIDE_FIELDS = (
1638
+ "id", "project_path", "session_id", "created_at", "expires_at", "status",
1639
+ "layer", "rule", "rule_class", "scope", "tool", "op", "path", "refusal",
1640
+ "justification", "resolved_at", "decided_by", "decision_note",
1641
+ )
1642
+
1643
+ #: Decisions made here are always attributed to the phone (§3.3 decided_by).
1644
+ _DECIDED_BY = "mobile"
1645
+
1646
+
1647
+ def _override_row(row: dict) -> dict:
1648
+ """One request row for the wire. Allowlisted, never the raw dict.
1649
+
1650
+ ``path_key`` is deliberately absent: it is a canonicalised local
1651
+ filesystem identity used for grant matching, and the phone already gets
1652
+ the human-readable ``path``.
1653
+ """
1654
+ out = {k: row.get(k) for k in _OVERRIDE_FIELDS}
1655
+ # Decision extras — present only once decided, so a pending card is exactly
1656
+ # the §3.3 shape and nothing more.
1657
+ for extra in ("grant_id", "grant_mode", "muted"):
1658
+ if row.get(extra) is not None:
1659
+ out[extra] = row.get(extra)
1660
+ return out
1661
+
1662
+
1663
+ def _override_audit(action: str, row: dict, *, confirmed: bool = False,
1664
+ detail_extra: dict | None = None) -> None:
1665
+ """Identifiers and rule globs only — never the justification.
1666
+
1667
+ The justification is agent-supplied untrusted text (§3.3); copying it into
1668
+ the activity log would put attacker-controlled prose into a surface the
1669
+ feed ships verbatim to every client.
1670
+ """
1671
+ detail = {"kind": "override", "action": action,
1672
+ "request_id": row.get("id", ""),
1673
+ "session_id": row.get("session_id", ""),
1674
+ "rule": row.get("rule", ""),
1675
+ "rule_class": row.get("rule_class", ""),
1676
+ "tool": row.get("tool", ""), "op": row.get("op", ""),
1677
+ "via": "oracle-mobile", "caller": _caller(),
1678
+ "confirmed": bool(confirmed)}
1679
+ detail.update(detail_extra or {})
1680
+ project = row.get("project_path") or ""
1681
+ if not project:
1682
+ return
1683
+ try:
1684
+ from services.activity_log import ActivityLog
1685
+ ActivityLog(str(project)).log("access_action", dict(detail))
1686
+ except Exception:
1687
+ pass
1688
+ try:
1689
+ from services.edit_ledger import EditLedger
1690
+ EditLedger(str(project)).log_edit(
1691
+ file=f"override://{row.get('id', '')}",
1692
+ change_type=f"override_{action}",
1693
+ summary=(f"{action} override {row.get('id', '')} "
1694
+ f"({row.get('rule', '')}) via mobile"),
1695
+ tags=["override", action], detail=dict(detail))
1696
+ except Exception:
1697
+ pass
1698
+
1699
+
1700
+ def _override_write_gate():
1701
+ """Capability + rate budget for every override mutation."""
1702
+ gate = _feature_or_404("override") or _feature_or_404("override_write")
1703
+ if gate:
1704
+ return gate
1705
+ return _security_gate()
1706
+
1707
+
1708
+ @bp.route("/overrides", methods=["GET"])
1709
+ def mobile_overrides_list():
1710
+ """Pending + recently-decided requests, newest first.
1711
+
1712
+ ``project`` is OPTIONAL and omitting it means every project (§15.5): the
1713
+ entire point of the inbox is answering while away from the desk, and a
1714
+ phone that must first pick the right project to discover it has a pending
1715
+ approval defeats that.
1716
+ """
1717
+ gate = _feature_or_404("override")
1718
+ if gate:
1719
+ return gate
1720
+ from services import override_requests as orq
1721
+ raw_project = (request.args.get("project") or "").strip()
1722
+ project_path = ""
1723
+ if raw_project:
1724
+ # A named project must still be one this token can see.
1725
+ resolved, err = _project_or_404(raw_project)
1726
+ if err:
1727
+ return err
1728
+ project_path = str(resolved)
1729
+ status = (request.args.get("status") or "").strip().lower()
1730
+ try:
1731
+ limit = int(request.args.get("limit") or _DEFAULT_LIMIT)
1732
+ except (TypeError, ValueError):
1733
+ limit = _DEFAULT_LIMIT
1734
+ limit = max(1, min(limit, _MAX_LIMIT))
1735
+ try:
1736
+ rows = orq.list_requests(project_path=project_path, status=status,
1737
+ limit=limit)
1738
+ except Exception as exc:
1739
+ return _svc_error(exc)
1740
+ if not raw_project:
1741
+ # Cross-project listing must not leak rows for projects this gateway
1742
+ # does not serve — the store is one file across every project on the
1743
+ # machine, including ones the scanner has since dropped.
1744
+ rows = [r for r in rows
1745
+ if _resolve_registered(str(r.get("project_path") or "")) is not None]
1746
+ return jsonify({
1747
+ "requests": [_override_row(r) for r in rows],
1748
+ "count": len(rows),
1749
+ "project": project_path,
1750
+ "status": status,
1751
+ "limit": limit,
1752
+ })
1753
+
1754
+
1755
+ @bp.route("/overrides/policy", methods=["GET"])
1756
+ def mobile_overrides_policy_get():
1757
+ """The effective (already merged + clamped) `override` section (§3.1).
1758
+
1759
+ Registered BEFORE `/overrides/<id>` would otherwise catch it — Flask
1760
+ matches static rules ahead of converters, but the ordering is made
1761
+ explicit here so a later refactor cannot turn `policy` into a request id.
1762
+ """
1763
+ gate = _feature_or_404("override")
1764
+ if gate:
1765
+ return gate
1766
+ from services import override_policy as opol
1767
+ resolved, err = _project_or_404(request.args.get("project") or "")
1768
+ if err:
1769
+ return err
1770
+ policy = opol.resolve(str(resolved))
1771
+ return jsonify({
1772
+ "path": str(resolved),
1773
+ "policy": policy.as_dict(),
1774
+ "layers": list(opol.LAYER_KEYS),
1775
+ "typed_confirm_layers": sorted(opol.TYPED_CONFIRM_LAYERS),
1776
+ "hard_max_ttl_s": opol.HARD_MAX_TTL_S,
1777
+ "defaults": {"max_ttl_s": opol.DEFAULTS["max_ttl_s"],
1778
+ "default_uses": opol.DEFAULTS["default_uses"]},
1779
+ "writable": bool(_cfg().get("mobile_override_write", True)),
1780
+ # Verbatim from the spec: policy is a floor, not a promise. Global and
1781
+ # project merge by TIGHTENING, so a layer shown true here can still be
1782
+ # false in effect if the other scope forbids it.
1783
+ "coverage_note": (
1784
+ "Project and global policy merge by tightening only: a layer is "
1785
+ "escalatable iff both scopes allow it, and the numeric limits take "
1786
+ "the smaller value. Grants are honoured on the PreToolUse hook "
1787
+ "surface; the c3_* MCP tools do not consult them yet (P2a)."
1788
+ ),
1789
+ })
1790
+
1791
+
1792
+ @bp.route("/overrides/policy", methods=["POST"])
1793
+ def mobile_overrides_policy_set():
1794
+ """Edit the project's `override` section.
1795
+
1796
+ Widening — enabling the feature, turning a layer on, raising a ceiling, or
1797
+ allowing session grants — needs a typed confirmation. Tightening never
1798
+ does: making the guard stricter is always allowed to be one tap.
1799
+ """
1800
+ gate = _override_write_gate()
1801
+ if gate:
1802
+ return gate
1803
+ from services import override_policy as opol
1804
+ data = request.get_json(silent=True) or {}
1805
+ resolved, err = _project_or_404(data.get("project") or "")
1806
+ if err:
1807
+ return err
1808
+
1809
+ section = data.get("override")
1810
+ if not isinstance(section, dict):
1811
+ return jsonify({"error": "body needs an 'override' object"}), 400
1812
+ unknown = sorted(set(section) - set(opol.DEFAULTS))
1813
+ if unknown:
1814
+ # §3.1: unknown keys are a hard error, never a silent no-op.
1815
+ return jsonify({
1816
+ "error": f"unknown override key(s): {', '.join(unknown)}",
1817
+ "valid": sorted(opol.DEFAULTS),
1818
+ }), 400
1819
+ layers = section.get("layers")
1820
+ if layers is not None:
1821
+ if not isinstance(layers, dict):
1822
+ return jsonify({"error": "'layers' must be an object"}), 400
1823
+ bad = sorted(set(layers) - set(opol.LAYER_KEYS))
1824
+ if bad:
1825
+ return jsonify({"error": f"unknown layer(s): {', '.join(bad)}",
1826
+ "valid": list(opol.LAYER_KEYS)}), 400
1827
+
1828
+ current = opol.resolve(str(resolved))
1829
+ widened = _override_widenings(current, section)
1830
+ if widened and not _confirmed(data, "widen"):
1831
+ return jsonify({
1832
+ "error": "this widens what a phone tap can approve: "
1833
+ + ", ".join(widened),
1834
+ "widens": widened,
1835
+ "needs_confirmation": True, "confirm_with": "widen",
1836
+ }), 400
1837
+
1838
+ try:
1839
+ result = _write_override_section(resolved, section)
1840
+ except (OSError, ValueError) as exc:
1841
+ return _svc_error(exc)
1842
+
1843
+ _override_audit("policy_set",
1844
+ {"id": "", "project_path": str(resolved), "rule": ""},
1845
+ confirmed=bool(widened),
1846
+ detail_extra={"widens": widened,
1847
+ "keys": sorted(section)})
1848
+ _gw_audit("mobile.override.policy_set",
1849
+ {"keys": sorted(section), "widens": widened})
1850
+ effective = opol.resolve(str(resolved))
1851
+ return jsonify({"path": str(resolved), "written": result,
1852
+ "policy": effective.as_dict(), "widened": widened})
1853
+
1854
+
1855
+ def _override_widenings(current, section: dict) -> list:
1856
+ """Which requested changes LOOSEN the policy. Names, for the challenge."""
1857
+ out = []
1858
+ if section.get("enabled") and not current.enabled:
1859
+ out.append("enabled")
1860
+ for key, want in (section.get("layers") or {}).items():
1861
+ if want and not current.layers.get(key, False):
1862
+ out.append(f"layers.{key}")
1863
+ for key in ("max_ttl_s", "default_uses", "request_ttl_s",
1864
+ "max_pending_per_session", "max_requests_per_hour"):
1865
+ if key in section:
1866
+ try:
1867
+ if int(section[key]) > int(getattr(current, key)):
1868
+ out.append(key)
1869
+ except (TypeError, ValueError):
1870
+ pass
1871
+ if section.get("allow_session_grants") and not current.allow_session_grants:
1872
+ out.append("allow_session_grants")
1873
+ return out
1874
+
1875
+
1876
+ def _write_override_section(project: Path, section: dict) -> dict:
1877
+ """Merge *section* into the project's `.c3/config.json` `override` block.
1878
+
1879
+ Project scope only. A global override policy governs every project on the
1880
+ machine, and the phone has no affordance for reviewing that blast radius —
1881
+ same call `mobile_enforcement_set` makes about machine-wide discipline.
1882
+ """
1883
+ cfg_file = Path(project) / ".c3" / "config.json"
1884
+ cfg_file.parent.mkdir(parents=True, exist_ok=True)
1885
+ cfg = {}
1886
+ if cfg_file.is_file():
1887
+ try:
1888
+ cfg = json.loads(cfg_file.read_text(encoding="utf-8")) or {}
1889
+ except ValueError as exc:
1890
+ raise ValueError(f"project config is not valid JSON: {exc}") from exc
1891
+ if not isinstance(cfg, dict):
1892
+ raise ValueError("project config is not a JSON object")
1893
+ block = dict(cfg.get("override") or {})
1894
+ for key, value in section.items():
1895
+ if key == "layers":
1896
+ merged = dict(block.get("layers") or {})
1897
+ merged.update({k: bool(v) for k, v in (value or {}).items()})
1898
+ block["layers"] = merged
1899
+ else:
1900
+ block[key] = value
1901
+ cfg["override"] = block
1902
+ tmp = cfg_file.with_name(f"{cfg_file.name}.tmp")
1903
+ tmp.write_text(json.dumps(cfg, indent=2, ensure_ascii=False),
1904
+ encoding="utf-8")
1905
+ tmp.replace(cfg_file)
1906
+ return block
1907
+
1908
+
1909
+ @bp.route("/overrides/<request_id>", methods=["GET"])
1910
+ def mobile_override_get(request_id):
1911
+ """One request, full context."""
1912
+ gate = _feature_or_404("override")
1913
+ if gate:
1914
+ return gate
1915
+ from services import override_requests as orq
1916
+ row = orq.get(str(request_id))
1917
+ if row is None:
1918
+ return jsonify({"error": "unknown request"}), 404
1919
+ if _resolve_registered(str(row.get("project_path") or "")) is None:
1920
+ # Same refusal as an unknown id: whether a request exists for a project
1921
+ # this gateway does not serve is not this token's business.
1922
+ return jsonify({"error": "unknown request"}), 404
1923
+ from services import override_policy as opol
1924
+ policy = opol.resolve(str(row.get("project_path") or "."))
1925
+ return jsonify({
1926
+ "request": _override_row(row),
1927
+ # What approving would actually cost, so the card can say it BEFORE
1928
+ # the tap rather than reporting a clamp afterwards.
1929
+ "policy": {
1930
+ "max_ttl_s": policy.max_ttl_s,
1931
+ "default_uses": policy.default_uses,
1932
+ "allow_session_grants": policy.allow_session_grants,
1933
+ "escalatable": policy.escalatable(str(row.get("rule_class") or "")),
1934
+ },
1935
+ "needs_typed_confirm": (
1936
+ str(row.get("rule_class") or "") in opol.TYPED_CONFIRM_LAYERS),
1937
+ "confirm_with": (
1938
+ row.get("rule") if str(row.get("rule_class") or "")
1939
+ in opol.TYPED_CONFIRM_LAYERS else ""),
1940
+ })
1941
+
1942
+
1943
+ @bp.route("/overrides/<request_id>/decide", methods=["POST"])
1944
+ def mobile_override_decide(request_id):
1945
+ """Approve (minting a grant) or deny one request.
1946
+
1947
+ The typed-confirmation challenges are computed here for the CLIENT's
1948
+ benefit — the authoritative check is inside ``override_requests.decide``,
1949
+ which refuses regardless of what this route believes. Two places compute
1950
+ it; only one enforces it.
1951
+ """
1952
+ gate = _override_write_gate()
1953
+ if gate:
1954
+ return gate
1955
+ from services import override_policy as opol
1956
+ from services import override_requests as orq
1957
+ data = request.get_json(silent=True) or {}
1958
+ decision = str(data.get("decision") or "").strip().lower()
1959
+ if decision not in (orq.DECISION_APPROVE, orq.DECISION_DENY):
1960
+ return jsonify({"error": "decision must be 'approve' or 'deny'"}), 400
1961
+ mode = str(data.get("mode") or orq.MODE_ONCE).strip().lower()
1962
+ if mode not in orq.MODES:
1963
+ return jsonify({"error": f"mode must be one of: "
1964
+ f"{', '.join(orq.MODES)}"}), 400
1965
+
1966
+ row = orq.get(str(request_id))
1967
+ if row is None or _resolve_registered(str(row.get("project_path") or "")) is None:
1968
+ return jsonify({"error": "unknown request"}), 404
1969
+ if row.get("status") != orq.STATUS_PENDING:
1970
+ # §12.7: it lapsed while the phone was showing it. 409, and the card
1971
+ # refreshes to the real status instead of silently minting a grant.
1972
+ return jsonify({"error": f"request is {row.get('status')}, not pending",
1973
+ "request": _override_row(row)}), 409
1974
+
1975
+ project_path = str(row.get("project_path") or ".")
1976
+ policy = opol.resolve(project_path)
1977
+ rule_class = str(row.get("rule_class") or "")
1978
+ confirm = data.get("confirm")
1979
+
1980
+ if decision == orq.DECISION_APPROVE:
1981
+ # (a) approving an access_deny / access_builtin request — the challenge
1982
+ # is the RULE GLOB itself, typed by hand (§8, §11 threat 1).
1983
+ if rule_class in opol.TYPED_CONFIRM_LAYERS and confirm != row.get("rule"):
1984
+ return jsonify({
1985
+ "error": f"approving an {rule_class} request needs the rule "
1986
+ "glob retyped by hand",
1987
+ "needs_confirmation": True,
1988
+ "confirm_with": row.get("rule"),
1989
+ }), 400
1990
+ # (b) a session grant, and only where policy allows one at all.
1991
+ if mode == orq.MODE_SESSION:
1992
+ if not policy.allow_session_grants:
1993
+ return jsonify({
1994
+ "error": "session grants are disabled for this project",
1995
+ "detail": "`override.allow_session_grants` is false — "
1996
+ "approve once instead.",
1997
+ }), 403
1998
+ if (rule_class not in opol.TYPED_CONFIRM_LAYERS
1999
+ and confirm != orq.CONFIRM_SESSION):
2000
+ return jsonify({
2001
+ "error": "a session grant is unlimited-use until it expires",
2002
+ "needs_confirmation": True,
2003
+ "confirm_with": orq.CONFIRM_SESSION,
2004
+ }), 400
2005
+
2006
+ requested_ttl = data.get("ttl_s")
2007
+ try:
2008
+ requested_ttl = int(requested_ttl) if requested_ttl is not None else None
2009
+ except (TypeError, ValueError):
2010
+ return jsonify({"error": "ttl_s must be an integer"}), 400
2011
+ requested_uses = data.get("uses")
2012
+ try:
2013
+ requested_uses = int(requested_uses) if requested_uses is not None else None
2014
+ except (TypeError, ValueError):
2015
+ return jsonify({"error": "uses must be an integer"}), 400
2016
+
2017
+ try:
2018
+ result = orq.decide(
2019
+ str(request_id), decision, uses=requested_uses,
2020
+ ttl_s=requested_ttl, note=str(data.get("note") or ""),
2021
+ decided_by=_DECIDED_BY, confirm=confirm, mode=mode,
2022
+ mute=bool(data.get("mute")))
2023
+ except orq.OverrideError as exc:
2024
+ _override_audit(f"{decision}_refused", row)
2025
+ return jsonify({"error": str(exc)}), 400
2026
+ except Exception as exc:
2027
+ return _svc_error(exc)
2028
+
2029
+ _override_audit(decision, result,
2030
+ confirmed=rule_class in opol.TYPED_CONFIRM_LAYERS,
2031
+ detail_extra={"mode": mode,
2032
+ "grant_id": result.get("grant_id", "")})
2033
+ _gw_audit(f"mobile.override.{decision}",
2034
+ {"request_id": str(request_id), "rule_class": rule_class,
2035
+ "mode": mode})
2036
+
2037
+ payload = {"request": _override_row(result), "decision": decision}
2038
+ if decision == orq.DECISION_APPROVE:
2039
+ granted_ttl = policy.clamp_ttl(requested_ttl)
2040
+ payload["grant"] = {
2041
+ "id": result.get("grant_id", ""),
2042
+ "mode": result.get("grant_mode", mode),
2043
+ "ttl_s": granted_ttl,
2044
+ "uses": policy.clamp_uses(
2045
+ opol.HARD_MAX_USES if mode == orq.MODE_SESSION
2046
+ else requested_uses),
2047
+ }
2048
+ # §8: a client asking for a week gets 15 minutes and is TOLD so.
2049
+ if requested_ttl is not None and granted_ttl < requested_ttl:
2050
+ payload["clamped"] = True
2051
+ payload["clamped_note"] = (
2052
+ f"requested ttl_s={requested_ttl} was clamped to "
2053
+ f"{granted_ttl}s by this project's override.max_ttl_s "
2054
+ f"({policy.max_ttl_s}s).")
2055
+ return jsonify(payload)
2056
+
2057
+
2058
+ @bp.route("/overrides/<request_id>/mute", methods=["POST"])
2059
+ def mobile_override_mute(request_id):
2060
+ """Deny, and suppress identical requests for this session (§8).
2061
+
2062
+ Never needs a confirmation: muting only ever makes the agent MORE blocked,
2063
+ and deny must always be the cheaper gesture than approve (§9).
2064
+ """
2065
+ gate = _override_write_gate()
2066
+ if gate:
2067
+ return gate
2068
+ from services import override_requests as orq
2069
+ data = request.get_json(silent=True) or {}
2070
+ row = orq.get(str(request_id))
2071
+ if row is None or _resolve_registered(str(row.get("project_path") or "")) is None:
2072
+ return jsonify({"error": "unknown request"}), 404
2073
+ if row.get("status") != orq.STATUS_PENDING:
2074
+ return jsonify({"error": f"request is {row.get('status')}, not pending",
2075
+ "request": _override_row(row)}), 409
2076
+ try:
2077
+ result = orq.decide(str(request_id), orq.DECISION_DENY,
2078
+ note=str(data.get("note") or ""),
2079
+ decided_by=_DECIDED_BY, mute=True)
2080
+ except orq.OverrideError as exc:
2081
+ return jsonify({"error": str(exc)}), 400
2082
+ except Exception as exc:
2083
+ return _svc_error(exc)
2084
+ _override_audit("mute", result, detail_extra={"muted": True})
2085
+ _gw_audit("mobile.override.mute", {"request_id": str(request_id)})
2086
+ return jsonify({"request": _override_row(result), "decision": "deny",
2087
+ "muted": True})
@@ -31,6 +31,11 @@ from services import override_policy as op_policy
31
31
 
32
32
  STORE_REL = ".c3/oracle/override_requests.json"
33
33
 
34
+ #: Mute store (P3, spec §8 "deny + suppress identical requests for this
35
+ #: session"). A SIBLING file rather than a key inside the request store,
36
+ #: because §3.3 freezes that file's shape as a JSON array of request rows.
37
+ MUTES_REL = ".c3/oracle/override_mutes.json"
38
+
34
39
  STATUS_PENDING = "pending"
35
40
  STATUS_APPROVED = "approved"
36
41
  STATUS_DENIED = "denied"
@@ -45,6 +50,20 @@ JUSTIFICATION_CAP = 400
45
50
  DECISION_APPROVE = "approve"
46
51
  DECISION_DENY = "deny"
47
52
 
53
+ #: Grant shape on approval (§8). ``once`` is the default and the only one that
54
+ #: needs no extra switch; ``session`` is an unlimited-uses grant for the rest
55
+ #: of the request TTL window and is gated on ``allow_session_grants`` plus a
56
+ #: typed confirmation, because it is the closest thing here to a policy change.
57
+ MODE_ONCE = "once"
58
+ MODE_SESSION = "session"
59
+ MODES = (MODE_ONCE, MODE_SESSION)
60
+
61
+ #: What the client must retype to get a session grant. Not the rule glob —
62
+ #: that challenge already means "I accept this access layer"; this one is a
63
+ #: separate question ("I accept an unlimited-uses grant") and must not be
64
+ #: answerable by the same keystrokes.
65
+ CONFIRM_SESSION = "session"
66
+
48
67
 
49
68
  class OverrideError(ValueError):
50
69
  """A refusal the agent (or CLI) should see verbatim."""
@@ -149,6 +168,108 @@ def list_requests(*, project_path: str = "", session_id: str = "",
149
168
  return out[:limit] if limit else out
150
169
 
151
170
 
171
+ # ── Mutes — "deny and stop asking" (§8, P3) ────────────────────────────────
172
+ # A mute is the answer to a retry loop: the user denied this exact call once
173
+ # and does not want to be asked again by this session. It is NOT a policy
174
+ # change and NOT a grant — it suppresses a *message*, so its worst case is an
175
+ # agent that stays blocked, which is the safe direction.
176
+ #
177
+ # Scope is deliberately (project, session, layer, rule, tool, op, path_key):
178
+ # byte-identical to the duplicate-suppression key `create()` already uses, so
179
+ # a mute is exactly "duplicate suppression that outlives the pending row".
180
+ # Session-bound because §8 says "for this session" — a new Claude Code session
181
+ # has a new problem and has earned the right to ask once.
182
+
183
+ def mutes_path() -> Path:
184
+ return Path.home() / MUTES_REL
185
+
186
+
187
+ def mute_key(project_path: str, *, session_id: str, layer: str, rule: str,
188
+ tool: str, op: str, path_key: str) -> str:
189
+ """The suppression identity. Same tuple as duplicate detection."""
190
+ return "\x1f".join([
191
+ og.path_key(project_path), str(session_id or ""), str(layer or ""),
192
+ str(rule or ""), str(tool or ""), str(op or ""), str(path_key or ""),
193
+ ])
194
+
195
+
196
+ def load_mutes() -> list:
197
+ """Every mute row. Corrupt reads as empty — a lost mute only means the
198
+ agent gets to ask once more, so failing open here is the safe direction
199
+ (the opposite call from grants, where failing open would be a capability).
200
+ """
201
+ path = mutes_path()
202
+ if not path.is_file():
203
+ return []
204
+ try:
205
+ data = json.loads(path.read_text(encoding="utf-8"))
206
+ except Exception:
207
+ return []
208
+ if not isinstance(data, list):
209
+ return []
210
+ return [r for r in data if isinstance(r, dict)]
211
+
212
+
213
+ def _save_mutes(rows: list) -> None:
214
+ path = mutes_path()
215
+ path.parent.mkdir(parents=True, exist_ok=True)
216
+ tmp = path.with_name(f"{path.name}.tmp")
217
+ tmp.write_text(json.dumps(rows, indent=2, ensure_ascii=False),
218
+ encoding="utf-8")
219
+ tmp.replace(path)
220
+
221
+
222
+ def is_muted(project_path: str, *, session_id: str, layer: str, rule: str,
223
+ tool: str, op: str, path_key: str) -> bool:
224
+ key = mute_key(project_path, session_id=session_id, layer=layer, rule=rule,
225
+ tool=tool, op=op, path_key=path_key)
226
+ return any(r.get("key") == key for r in load_mutes())
227
+
228
+
229
+ def add_mute(row: dict) -> dict:
230
+ """Suppress future requests identical to *row*. Idempotent."""
231
+ project_path = row.get("project_path", ".")
232
+ key = mute_key(project_path, session_id=row.get("session_id", ""),
233
+ layer=row.get("layer", ""), rule=row.get("rule", ""),
234
+ tool=row.get("tool", ""), op=row.get("op", ""),
235
+ path_key=row.get("path_key", ""))
236
+ rows = load_mutes()
237
+ for existing in rows:
238
+ if existing.get("key") == key:
239
+ return existing
240
+ mute = {
241
+ "key": key,
242
+ "project_path": project_path,
243
+ "session_id": row.get("session_id", ""),
244
+ "layer": row.get("layer", ""),
245
+ "rule": row.get("rule", ""),
246
+ "tool": row.get("tool", ""),
247
+ "op": row.get("op", ""),
248
+ "path_key": row.get("path_key", ""),
249
+ "path": row.get("path", ""),
250
+ "request_id": row.get("id", ""),
251
+ "muted_at": og.iso(og.now()),
252
+ }
253
+ rows.append(mute)
254
+ _save_mutes(rows)
255
+ return mute
256
+
257
+
258
+ def clear_mutes(project_path: str = "", session_id: str = "") -> int:
259
+ """Drop mutes, optionally narrowed. Human surfaces only. Returns count."""
260
+ rows = load_mutes()
261
+ key = og.path_key(project_path) if project_path else ""
262
+ keep = [r for r in rows
263
+ if (key and og.path_key(r.get("project_path", "")) != key)
264
+ or (session_id and r.get("session_id") != session_id)]
265
+ if not key and not session_id:
266
+ keep = []
267
+ removed = len(rows) - len(keep)
268
+ if removed:
269
+ _save_mutes(keep)
270
+ return removed
271
+
272
+
152
273
  # ── Creation — the agent surface (§7) ──────────────────────────────────────
153
274
 
154
275
  def classify(project_path: str, *, denial=None, layer: str = "",
@@ -210,6 +331,17 @@ def create(project_path: str, *, session_id: str, tool: str, op: str, path,
210
331
  f"{op_policy.TAG_NOT_ESCALATABLE} that file is part of the "
211
332
  "credential vault or the override policy itself.")
212
333
 
334
+ # A mute is a standing "no, and stop asking" from the user (§8). It is
335
+ # checked BEFORE the rate limits so a muted retry loop costs the agent's
336
+ # hourly budget nothing — the user already answered, and the honest
337
+ # outcome is a refusal that names the reason.
338
+ if is_muted(project_path, session_id=session_id, layer=gate, rule=rule,
339
+ tool=tool, op=op, path_key=key):
340
+ raise OverrideError(
341
+ f"{op_policy.TAG_NOT_ESCALATABLE} the user denied this exact "
342
+ "request and muted it for this session. Do not ask again — mark "
343
+ "the step blocked and tell the user what you needed.")
344
+
213
345
  rows = load()
214
346
  _refresh(rows)
215
347
  proj_key = og.path_key(project_path)
@@ -293,6 +425,41 @@ def _notify(project_path: str, row: dict, policy) -> None:
293
425
  pass # a missing notification must never fail the request
294
426
 
295
427
 
428
+ def _notify_decision(project_path: str, row: dict, grant=None) -> None:
429
+ """Append the OUTCOME to the notification feed (§8).
430
+
431
+ The request notification says "someone should decide this"; without a
432
+ second line the feed never records that anyone did, so a user scrolling
433
+ back sees an eternally-open question they actually answered from the
434
+ phone. Severity is deliberately ``info``: the decision is the calm end of
435
+ the story, and re-alerting on your own tap trains people to ignore the
436
+ feed.
437
+ """
438
+ try:
439
+ from services.notifications import NotificationStore # noqa: PLC0415
440
+ status = row.get("status", "")
441
+ name = Path(str(row.get("path", ""))).name
442
+ if status == STATUS_APPROVED:
443
+ detail = "once" if row.get("grant_mode") != MODE_SESSION \
444
+ else "for this session"
445
+ message = (f"Granted {row.get('tool')} {row.get('op')} on {name} "
446
+ f"{detail} (rule {row.get('rule')}). "
447
+ f"Grant {(grant or {}).get('id', '')} expires "
448
+ f"{(grant or {}).get('expires_at', '')}.")
449
+ else:
450
+ message = (f"Denied {row.get('tool')} {row.get('op')} on {name} "
451
+ f"(rule {row.get('rule')})."
452
+ + (" Muted for this session." if row.get("muted") else ""))
453
+ NotificationStore(str(project_path)).add(
454
+ agent="override",
455
+ severity="info",
456
+ title=f"Override {status}: {name}",
457
+ message=message,
458
+ )
459
+ except Exception:
460
+ pass # the decision already happened; a missing feed line never undoes it
461
+
462
+
296
463
  def withdraw(request_id: str, session_id: str) -> dict:
297
464
  """An agent cancelling its OWN pending request (e.g. it found another way)."""
298
465
  rows = load()
@@ -315,7 +482,8 @@ def withdraw(request_id: str, session_id: str) -> dict:
315
482
 
316
483
  def decide(request_id: str, decision: str, *, uses: int | None = None,
317
484
  ttl_s: int | None = None, note: str = "", decided_by: str = "cli",
318
- confirm: str | None = None) -> dict:
485
+ confirm: str | None = None, mode: str = MODE_ONCE,
486
+ mute: bool = False) -> dict:
319
487
  """Approve (minting a grant) or deny one request. **Human callers only.**
320
488
 
321
489
  Approving an ``access_deny`` / ``access_builtin`` request requires the rule
@@ -324,6 +492,8 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
324
492
  """
325
493
  if decision not in (DECISION_APPROVE, DECISION_DENY):
326
494
  raise OverrideError("decision must be 'approve' or 'deny'.")
495
+ if mode not in MODES:
496
+ raise OverrideError(f"mode must be one of: {', '.join(MODES)}.")
327
497
 
328
498
  rows = load()
329
499
  _refresh(rows)
@@ -344,12 +514,18 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
344
514
  row["resolved_at"] = og.iso(now)
345
515
  row["decided_by"] = decided_by
346
516
  row["decision_note"] = str(note or "")[:JUSTIFICATION_CAP] or None
517
+ row["muted"] = bool(mute)
347
518
  _save(rows)
519
+ # Mute AFTER the row is persisted: a mute whose request never landed
520
+ # as denied would suppress a question the user never actually answered.
521
+ if mute:
522
+ add_mute(row)
348
523
  og.audit(project_path, og.EV_DENIED, {
349
524
  "request_id": request_id, "session_id": row.get("session_id", ""),
350
525
  "rule": row.get("rule", ""), "path": row.get("path_key", ""),
351
- "decided_by": decided_by,
526
+ "decided_by": decided_by, "muted": bool(mute),
352
527
  })
528
+ _notify_decision(project_path, row, grant=None)
353
529
  return row
354
530
 
355
531
  policy = op_policy.resolve(project_path)
@@ -363,6 +539,21 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
363
539
  f"approving an {layers_key} request needs the rule retyped: "
364
540
  f"confirm='{row.get('rule')}'")
365
541
 
542
+ if mode == MODE_SESSION:
543
+ # Two independent gates, both required. The switch is the user's
544
+ # standing policy; the challenge is their answer right now.
545
+ if not policy.allow_session_grants:
546
+ raise OverrideError(
547
+ "session grants are disabled for this project "
548
+ "(`override.allow_session_grants` is false). Approve once "
549
+ "instead, or change the policy on the desktop.")
550
+ if confirm != CONFIRM_SESSION and layers_key not in op_policy.TYPED_CONFIRM_LAYERS:
551
+ raise OverrideError(
552
+ "a session grant is unlimited-use until it expires: "
553
+ f"confirm='{CONFIRM_SESSION}'")
554
+ # Unlimited uses within the (already clamped) TTL window.
555
+ uses = op_policy.HARD_MAX_USES
556
+
366
557
  grant = og.mint(project_path, session_id=row.get("session_id", ""),
367
558
  layer=row.get("layer", ""), rule=row.get("rule", ""),
368
559
  tool=row.get("tool", ""), op=row.get("op", ""),
@@ -375,5 +566,7 @@ def decide(request_id: str, decision: str, *, uses: int | None = None,
375
566
  row["decided_by"] = decided_by
376
567
  row["decision_note"] = str(note or "")[:JUSTIFICATION_CAP] or None
377
568
  row["grant_id"] = grant["id"]
569
+ row["grant_mode"] = mode
378
570
  _save(rows)
571
+ _notify_decision(project_path, row, grant=grant)
379
572
  return row