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 +1 -1
- {code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/METADATA +1 -1
- {code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/RECORD +10 -10
- oracle/config.py +9 -0
- oracle/services/mobile_api.py +469 -0
- services/override_requests.py +195 -2
- {code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-context-control
|
|
3
|
-
Version: 2.
|
|
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=
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
265
|
-
code_context_control-2.
|
|
266
|
-
code_context_control-2.
|
|
267
|
-
code_context_control-2.
|
|
268
|
-
code_context_control-2.
|
|
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,
|
oracle/services/mobile_api.py
CHANGED
|
@@ -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})
|
services/override_requests.py
CHANGED
|
@@ -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
|
|
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
|
|
File without changes
|
{code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{code_context_control-2.70.0.dist-info → code_context_control-2.71.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|