keywright-server 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. keywright_server/__init__.py +3 -0
  2. keywright_server/__main__.py +71 -0
  3. keywright_server/access.py +131 -0
  4. keywright_server/access_view.py +144 -0
  5. keywright_server/actions.py +30 -0
  6. keywright_server/approvals.py +433 -0
  7. keywright_server/audit.py +58 -0
  8. keywright_server/bundles.py +87 -0
  9. keywright_server/config.py +102 -0
  10. keywright_server/db.py +119 -0
  11. keywright_server/discovery.py +277 -0
  12. keywright_server/events.py +78 -0
  13. keywright_server/friction.py +242 -0
  14. keywright_server/integrations.py +64 -0
  15. keywright_server/inventory.py +415 -0
  16. keywright_server/inventory_mcp.py +288 -0
  17. keywright_server/jev.py +80 -0
  18. keywright_server/knowledge.py +403 -0
  19. keywright_server/knowledge_chunking.py +118 -0
  20. keywright_server/knowledge_index.py +233 -0
  21. keywright_server/knowledge_mcp.py +82 -0
  22. keywright_server/llm.py +338 -0
  23. keywright_server/llm_proxy.py +271 -0
  24. keywright_server/llm_usage.py +99 -0
  25. keywright_server/mail.py +312 -0
  26. keywright_server/mcp_oauth.py +422 -0
  27. keywright_server/mcp_proxy.py +128 -0
  28. keywright_server/mcp_server.py +1025 -0
  29. keywright_server/models.py +442 -0
  30. keywright_server/network_map.py +128 -0
  31. keywright_server/notify/__init__.py +8 -0
  32. keywright_server/notify/base.py +156 -0
  33. keywright_server/notify/homeassistant.py +221 -0
  34. keywright_server/notify/log.py +30 -0
  35. keywright_server/notify/manager.py +131 -0
  36. keywright_server/notify/webhook.py +79 -0
  37. keywright_server/nudges.py +121 -0
  38. keywright_server/omada.py +179 -0
  39. keywright_server/policy.py +265 -0
  40. keywright_server/policy_preview.py +151 -0
  41. keywright_server/reconciliation.py +188 -0
  42. keywright_server/result_inspection.py +293 -0
  43. keywright_server/semantic.py +451 -0
  44. keywright_server/semantic_questions.json +120 -0
  45. keywright_server/settings_store.py +44 -0
  46. keywright_server/ssh.py +290 -0
  47. keywright_server/tool_access.py +260 -0
  48. keywright_server/usage.py +121 -0
  49. keywright_server/web/__init__.py +0 -0
  50. keywright_server/web/admin.py +1126 -0
  51. keywright_server/web/app.py +154 -0
  52. keywright_server/web/auth.py +144 -0
  53. keywright_server/web/discovery_ui.py +112 -0
  54. keywright_server/web/friction_ui.py +92 -0
  55. keywright_server/web/inventory_ui.py +192 -0
  56. keywright_server/web/knowledge_ui.py +177 -0
  57. keywright_server/web/mail_ui.py +190 -0
  58. keywright_server/web/mcp_oauth_ui.py +87 -0
  59. keywright_server/web/network_map_ui.py +37 -0
  60. keywright_server/web/oidc.py +231 -0
  61. keywright_server/web/security.py +77 -0
  62. keywright_server/web/templates/_activity_row.html +25 -0
  63. keywright_server/web/templates/_agent_setup.html +50 -0
  64. keywright_server/web/templates/_collections_style.html +88 -0
  65. keywright_server/web/templates/_constraints.html +6 -0
  66. keywright_server/web/templates/_decide.html +39 -0
  67. keywright_server/web/templates/_device_kind_picker.html +66 -0
  68. keywright_server/web/templates/_hazards.html +6 -0
  69. keywright_server/web/templates/_icons.html +19 -0
  70. keywright_server/web/templates/_infrastructure_nav.html +8 -0
  71. keywright_server/web/templates/_integration_style.html +30 -0
  72. keywright_server/web/templates/_inventory_forms.html +36 -0
  73. keywright_server/web/templates/_inventory_style.html +36 -0
  74. keywright_server/web/templates/_live.html +10 -0
  75. keywright_server/web/templates/_mail_nav.html +5 -0
  76. keywright_server/web/templates/_mail_style.html +10 -0
  77. keywright_server/web/templates/_network_map_script.html +5 -0
  78. keywright_server/web/templates/_nudge_card.html +12 -0
  79. keywright_server/web/templates/_patterns.html +6 -0
  80. keywright_server/web/templates/_policy_preview_results.html +13 -0
  81. keywright_server/web/templates/_request_card.html +46 -0
  82. keywright_server/web/templates/_semantic.html +10 -0
  83. keywright_server/web/templates/_topology_controller.js +217 -0
  84. keywright_server/web/templates/_topology_layout.js +174 -0
  85. keywright_server/web/templates/_topology_render.js +117 -0
  86. keywright_server/web/templates/access.html +119 -0
  87. keywright_server/web/templates/agents.html +49 -0
  88. keywright_server/web/templates/audit.html +6 -0
  89. keywright_server/web/templates/base.html +198 -0
  90. keywright_server/web/templates/dashboard.html +226 -0
  91. keywright_server/web/templates/decision.html +26 -0
  92. keywright_server/web/templates/friction.html +36 -0
  93. keywright_server/web/templates/friction_review.html +21 -0
  94. keywright_server/web/templates/grants.html +61 -0
  95. keywright_server/web/templates/integration_picker.html +47 -0
  96. keywright_server/web/templates/inventory.html +21 -0
  97. keywright_server/web/templates/inventory_asset.html +42 -0
  98. keywright_server/web/templates/inventory_discovery.html +32 -0
  99. keywright_server/web/templates/inventory_discovery_review.html +34 -0
  100. keywright_server/web/templates/inventory_map.html +45 -0
  101. keywright_server/web/templates/inventory_networks.html +12 -0
  102. keywright_server/web/templates/inventory_subnet.html +41 -0
  103. keywright_server/web/templates/inventory_topology.html +22 -0
  104. keywright_server/web/templates/knowledge.html +25 -0
  105. keywright_server/web/templates/knowledge_document.html +7 -0
  106. keywright_server/web/templates/knowledge_proposal.html +7 -0
  107. keywright_server/web/templates/login.html +9 -0
  108. keywright_server/web/templates/mail.html +45 -0
  109. keywright_server/web/templates/mail_rules.html +24 -0
  110. keywright_server/web/templates/mail_settings.html +19 -0
  111. keywright_server/web/templates/mcp_oauth_continue.html +14 -0
  112. keywright_server/web/templates/policies.html +30 -0
  113. keywright_server/web/templates/request_detail.html +27 -0
  114. keywright_server/web/templates/requests.html +9 -0
  115. keywright_server/web/templates/resource_form.html +198 -0
  116. keywright_server/web/templates/resources.html +76 -0
  117. keywright_server/web/templates/settings.html +53 -0
  118. keywright_server/web/templates/setup.html +8 -0
  119. keywright_server/web/templates/usage.html +84 -0
  120. keywright_server/web/usage_ui.py +41 -0
  121. keywright_server-0.0.1.dist-info/METADATA +152 -0
  122. keywright_server-0.0.1.dist-info/RECORD +127 -0
  123. keywright_server-0.0.1.dist-info/WHEEL +4 -0
  124. keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
  125. keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
  126. keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
  127. keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
@@ -0,0 +1,121 @@
1
+ """Nudges: an agent asking for the human in the conversation.
2
+
3
+ An approval request is the agent asking to *do* something and waiting on the answer. A nudge is the
4
+ opposite shape: the agent has a question only the human can settle, and the answer comes back in the
5
+ conversation, not through Keywright. So a nudge grants nothing, blocks nothing and is never gated by
6
+ policy - it is a push notification plus a card that stays on the dashboard until the human clears it.
7
+
8
+ What it does need is a limit. A ping costs the human an interruption, so an agent gets one per
9
+ minute and may leave at most a handful outstanding; repeats of the same question are folded into
10
+ the nudge already on the phone rather than buzzing again.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from datetime import timedelta
17
+ from typing import NamedTuple
18
+
19
+ from sqlalchemy import select, update
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from . import actions, audit, events
23
+ from .config import settings
24
+ from .models import Agent, Nudge, now
25
+
26
+ log = logging.getLogger("keywright.nudges")
27
+
28
+ COOLDOWN_S = 60 # between two different pings from one agent
29
+ MAX_OPEN = 5 # outstanding pings one agent may leave on the dashboard
30
+ MAX_MESSAGE = 1000
31
+ MAX_CONTEXT = 200
32
+ DISMISS = "KWN_DISMISS_" # phone button: KWN_DISMISS_<nudge id>.<mac>
33
+
34
+
35
+ class Ping(NamedTuple):
36
+ """What happened to an attempt to ping the human."""
37
+
38
+ nudge: Nudge | None
39
+ status: str # sent | duplicate | throttled | backlog
40
+ retry_after: int = 0
41
+
42
+ @property
43
+ def delivered(self) -> bool:
44
+ return self.status in ("sent", "duplicate")
45
+
46
+
47
+ def dismiss_action(nudge_id: str) -> str:
48
+ return actions.sign(DISMISS + nudge_id)
49
+
50
+
51
+ def parse_dismiss_action(action: str) -> str | None:
52
+ """The nudge ID a phone button carries, or None if it is not one of ours."""
53
+ body = actions.verify(action)
54
+ return body[len(DISMISS):] or None if body and body.startswith(DISMISS) else None
55
+
56
+
57
+ async def open_nudges(db: AsyncSession, agent_id: str | None = None) -> list[Nudge]:
58
+ q = select(Nudge).where(Nudge.cleared_at.is_(None)).order_by(Nudge.created_at.desc())
59
+ if agent_id:
60
+ q = q.where(Nudge.agent_id == agent_id)
61
+ return list((await db.execute(q)).scalars().all())
62
+
63
+
64
+ async def create(db: AsyncSession, agent: Agent, session_id: str | None, message: str,
65
+ context: str = "") -> Ping:
66
+ """Ask for the human. Returns what became of the ask; the caller tells the agent."""
67
+ message = message.strip()[:MAX_MESSAGE]
68
+ context = context.strip()[:MAX_CONTEXT]
69
+ if not message:
70
+ raise ValueError("A nudge needs a message: say what you need from them.")
71
+ at = now()
72
+ mine = await open_nudges(db, agent.id)
73
+ same = next((n for n in mine if n.message == message and n.session_id == session_id), None)
74
+ if same is not None:
75
+ # The same question, still on their phone. Buzzing again would not add anything.
76
+ return Ping(same, "duplicate")
77
+ if len(mine) >= MAX_OPEN:
78
+ return Ping(mine[0] if mine else None, "backlog")
79
+ last = mine[0].created_at if mine else None
80
+ if last is not None and (at - last).total_seconds() < COOLDOWN_S:
81
+ return Ping(mine[0], "throttled", int(COOLDOWN_S - (at - last).total_seconds()) + 1)
82
+
83
+ nudge = Nudge(agent_id=agent.id, session_id=session_id, message=message, context=context, created_at=at)
84
+ db.add(nudge)
85
+ await db.commit()
86
+ await db.refresh(nudge)
87
+ await audit.record(db, tool="attention", decision="sent", agent=agent, session_id=session_id,
88
+ detail={"nudge_id": nudge.id, "message": message, "context": context})
89
+ from .notify.manager import manager
90
+
91
+ await manager.notify_nudge(nudge, agent)
92
+ events.publish("nudge.created", id=nudge.id)
93
+ return Ping(nudge, "sent")
94
+
95
+
96
+ async def clear(db: AsyncSession, nudge_id: str, via: str) -> Nudge | None:
97
+ """Mark a nudge answered and take it off the phone. Idempotent: two taps clear it once."""
98
+ changed = await db.execute(update(Nudge).where(
99
+ Nudge.id == nudge_id, Nudge.cleared_at.is_(None),
100
+ ).values(cleared_at=now(), cleared_via=via).execution_options(synchronize_session=False))
101
+ await db.commit()
102
+ nudge = await db.get(Nudge, nudge_id, populate_existing=True)
103
+ if nudge is None or changed.rowcount != 1:
104
+ return nudge
105
+ await audit.record(db, tool="attention.clear", decision="cleared", agent=nudge.agent,
106
+ session_id=nudge.session_id, detail={"nudge_id": nudge.id, "via": via})
107
+ from .notify.manager import manager
108
+
109
+ await manager.nudge_cleared(nudge)
110
+ events.publish("nudge.cleared", id=nudge.id)
111
+ return nudge
112
+
113
+
114
+ async def expire_stale(db: AsyncSession) -> int:
115
+ """Nobody is still waiting on a day-old question. Clear it so the phone and dashboard agree."""
116
+ cutoff = now() - timedelta(seconds=settings.nudge_ttl_s)
117
+ stale = (await db.execute(select(Nudge.id).where(
118
+ Nudge.cleared_at.is_(None), Nudge.created_at <= cutoff))).scalars().all()
119
+ for nudge_id in stale:
120
+ await clear(db, nudge_id, via="expired")
121
+ return len(stale)
@@ -0,0 +1,179 @@
1
+ """Omada Open API tools, exposed through the existing MCP approval boundary.
2
+
3
+ Only the broker sees client credentials and access tokens. Endpoints are fixed:
4
+ tool arguments cannot select an origin, API path, HTTP method, or auth headers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import ssl
13
+ import time
14
+ from collections import OrderedDict
15
+ from typing import Annotated, Any
16
+ from urllib.parse import urlsplit
17
+
18
+ import httpx
19
+ import mcp_types as types
20
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
21
+
22
+
23
+ class OmadaError(ValueError):
24
+ """Safe to display or audit; never includes controller response bodies."""
25
+
26
+
27
+ Identifier = Annotated[str, Field(strict=True, pattern=r"^[A-Za-z0-9_-]{1,128}$")]
28
+
29
+
30
+ class Page(BaseModel):
31
+ model_config = ConfigDict(extra="forbid", strict=True)
32
+ page: int = Field(default=1, ge=1)
33
+ page_size: int = Field(default=100, ge=1, le=1000)
34
+
35
+
36
+ class SitePage(Page):
37
+ site_id: Identifier
38
+
39
+
40
+ class APDetails(BaseModel):
41
+ model_config = ConfigDict(extra="forbid", strict=True)
42
+ site_id: Identifier
43
+ ap_mac: Annotated[str, Field(strict=True, pattern=r"^[0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5}$")]
44
+
45
+
46
+ OPERATIONS = {
47
+ "omada_list_sites": (Page, "sites", "List accessible Omada sites, one page at a time."),
48
+ "omada_list_devices": (SitePage, "sites/{site_id}/devices",
49
+ "List devices (including routers), status and inventory in an Omada site."),
50
+ "omada_get_ap": (APDetails, "sites/{site_id}/aps/{ap_mac}",
51
+ "Read AP details, including its reported wireless mesh uplink."),
52
+ "omada_list_clients": (SitePage, "sites/{site_id}/clients",
53
+ "List clients in an Omada site, one page at a time."),
54
+ }
55
+
56
+ # Short-lived bearer tokens stay in memory. The fingerprint includes the connection
57
+ # and credentials, so editing a resource never reuses the previous identity's token.
58
+ _tokens: OrderedDict[str, tuple[float, str]] = OrderedDict()
59
+
60
+
61
+ def validate_config(config: dict[str, Any]) -> None:
62
+ url = config.get("url", "")
63
+ try:
64
+ parsed = urlsplit(url)
65
+ valid = (parsed.scheme == "https" and parsed.hostname and parsed.port != 0
66
+ and not parsed.username and not parsed.password
67
+ and parsed.path in ("", "/") and not parsed.query and not parsed.fragment
68
+ and not any(c.isspace() for c in url) and "\\" not in url)
69
+ except ValueError:
70
+ valid = False
71
+ if not valid:
72
+ raise OmadaError("Use the HTTPS Interface Access Address, including its port, without an API path.")
73
+ for key, label in (("omada_id", "Omada ID"), ("client_id", "Client ID")):
74
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", config.get(key, "")):
75
+ raise OmadaError(f"{label} must contain only letters, numbers, underscores and hyphens.")
76
+ if not config.get("client_secret") or config["client_secret"] == "***":
77
+ raise OmadaError("An Omada Client Secret is required.")
78
+ if not isinstance(config.get("verify_tls", True), bool):
79
+ raise OmadaError("TLS verification must be enabled or disabled explicitly.")
80
+
81
+
82
+ def list_tools() -> list[types.Tool]:
83
+ return [types.Tool(name=name, description=description, input_schema=model.model_json_schema(),
84
+ annotations=types.ToolAnnotations(read_only_hint=True, destructive_hint=False,
85
+ idempotent_hint=True))
86
+ for name, (model, _, description) in OPERATIONS.items()]
87
+
88
+
89
+ def _result(response: httpx.Response) -> Any:
90
+ if not response.is_success:
91
+ raise OmadaError(f"Omada returned HTTP {response.status_code}; check the address and app permissions.")
92
+ try:
93
+ payload = response.json()
94
+ except ValueError:
95
+ raise OmadaError("Omada returned invalid JSON; check the Interface Access Address.") from None
96
+ if not isinstance(payload, dict) or type(payload.get("errorCode")) is not int:
97
+ raise OmadaError("Omada returned an unexpected API response.")
98
+ if payload["errorCode"] != 0:
99
+ raise OmadaError(f"Omada API error {payload['errorCode']}; check app credentials, site privileges "
100
+ "and controller API compatibility.")
101
+ if "result" not in payload:
102
+ raise OmadaError("Omada response is missing its result.")
103
+ return payload["result"]
104
+
105
+
106
+ async def _token(client: httpx.AsyncClient, config: dict[str, Any], key: str) -> str:
107
+ cached = _tokens.get(key)
108
+ if cached and time.monotonic() < cached[0]:
109
+ _tokens.move_to_end(key)
110
+ return cached[1]
111
+ result = _result(await client.post(config["url"].rstrip("/") + "/openapi/authorize/token",
112
+ params={"grant_type": "client_credentials"},
113
+ json={"omadacId": config["omada_id"], "client_id": config["client_id"],
114
+ "client_secret": config["client_secret"]}))
115
+ if not isinstance(result, dict):
116
+ raise OmadaError("Omada returned an invalid token response.")
117
+ token, expires = result.get("accessToken"), result.get("expiresIn")
118
+ if (not isinstance(token, str) or not token or not token.isascii()
119
+ or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in token)
120
+ or type(expires) is not int or expires <= 0):
121
+ raise OmadaError("Omada returned an invalid access token or expiry.")
122
+ _tokens[key] = (time.monotonic() + max(0, min(expires, 7200) - 30), token)
123
+ _tokens.move_to_end(key)
124
+ while len(_tokens) > 128:
125
+ _tokens.popitem(last=False)
126
+ return token
127
+
128
+
129
+ async def request(config: dict[str, Any], tool: str, arguments: dict[str, Any]) -> Any:
130
+ if tool not in OPERATIONS:
131
+ raise OmadaError("Unknown Omada tool.")
132
+ model, path, _ = OPERATIONS[tool]
133
+ try:
134
+ args = model.model_validate(arguments)
135
+ except ValidationError:
136
+ raise OmadaError("Invalid Omada arguments; use the tool schema.") from None
137
+ validate_config(config)
138
+ verify: bool | ssl.SSLContext = config.get("verify_tls", True)
139
+ if verify and config.get("ca_bundle"):
140
+ try:
141
+ verify = ssl.create_default_context(cafile=config["ca_bundle"])
142
+ except (OSError, ssl.SSLError):
143
+ raise OmadaError("Could not load the configured Omada CA certificate bundle.") from None
144
+ key = hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
145
+ path_args = args.model_dump()
146
+ if isinstance(args, APDetails):
147
+ path_args["ap_mac"] = args.ap_mac.replace(":", "-").upper()
148
+ params = {"page": args.page, "pageSize": args.page_size} if isinstance(args, Page) else {}
149
+ url = (config["url"].rstrip("/") + "/openapi/v1/" + config["omada_id"] + "/"
150
+ + path.format(**path_args))
151
+ try:
152
+ async with httpx.AsyncClient(verify=verify, timeout=30, follow_redirects=False, trust_env=False) as client:
153
+ for attempt in range(2):
154
+ token = await _token(client, config, key)
155
+ response = await client.get(url, params=params,
156
+ headers={"Authorization": f"AccessToken={token}"})
157
+ if response.status_code == 401:
158
+ _tokens.pop(key, None)
159
+ if attempt == 0:
160
+ continue
161
+ return _result(response)
162
+ except httpx.HTTPError:
163
+ raise OmadaError("Could not reach the Omada API; check connectivity and TLS certificate settings.") from None
164
+
165
+
166
+ async def call_tool(config: dict[str, Any], tool: str, arguments: dict[str, Any]) -> types.CallToolResult:
167
+ try:
168
+ result = await request(config, tool, arguments)
169
+ except OmadaError as e:
170
+ return types.CallToolResult(is_error=True, content=[types.TextContent(type="text", text=str(e))])
171
+ return types.CallToolResult(content=[types.TextContent(type="text", text=json.dumps(result))])
172
+
173
+
174
+ async def probe(config: dict[str, Any]) -> tuple[bool, str]:
175
+ try:
176
+ await request(config, "omada_list_sites", {"page_size": 1})
177
+ except OmadaError as e:
178
+ return False, str(e)
179
+ return True, "Connected to Omada Open API; site listing succeeded."
@@ -0,0 +1,265 @@
1
+ """The decision engine.
2
+
3
+ For every required pattern, in order:
4
+ 1. a matching `deny` policy -> denied
5
+ 2. an active grant for this agent -> allowed (once-grants are consumed in the same transaction)
6
+ 3. a matching `allow` policy -> allowed
7
+ 4. the resource's default mode -> open: allowed / locked: locked / approval: needs a request
8
+
9
+ An operation flagged high or critical by `hazards` skips 3 and 4 and, in 2, accepts only a once
10
+ grant bound to this exact operation: whatever standing access exists, a human has to approve that
11
+ command line, and the approval is used up by it. Critical operations never reach here; the tool
12
+ layer refuses them before asking.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+
20
+ from keywright import patterns as pat
21
+ from keywright.hazards import blocks, worst
22
+ from sqlalchemy import select, update
23
+ from sqlalchemy.ext.asyncio import AsyncSession
24
+
25
+ from . import llm
26
+ from .access import binds_operation, constraint_label, grant_constraints, operation_matches, target_of
27
+ from .models import AccessRequest, Agent, Grant, Policy, Resource, now
28
+
29
+
30
+ @dataclass
31
+ class Verdict:
32
+ allowed: bool = False
33
+ status: str = "pending" # allow | deny | locked | pending | unknown
34
+ missing: list[str] = field(default_factory=list) # patterns needing approval
35
+ denied: list[str] = field(default_factory=list)
36
+ locked: list[str] = field(default_factory=list)
37
+ unknown: list[str] = field(default_factory=list) # no such resource
38
+ consumed_grants: list[str] = field(default_factory=list)
39
+ matched: dict[str, str] = field(default_factory=dict) # pattern -> what allowed it
40
+ bound: dict[str, str] = field(default_factory=dict) # pattern -> how far that grant or rule reaches
41
+ request_ids: list[str] = field(default_factory=list)
42
+ hazard: str | None = None # worst hazard level of the operation, if any
43
+
44
+
45
+ async def evaluate(
46
+ db: AsyncSession,
47
+ agent: Agent,
48
+ session_id: str | None,
49
+ required: list[str],
50
+ *,
51
+ consume: bool = True,
52
+ operation: dict | None = None,
53
+ discovery: bool = False,
54
+ ) -> Verdict:
55
+ """Evaluate `required` for `agent`. With consume=True, matching once-grants are decremented
56
+ and the transaction is committed when everything is allowed."""
57
+ at = now()
58
+ if discovery and consume:
59
+ raise ValueError("Discovery cannot consume grants")
60
+ expected_hash = agent.token_hash
61
+ current = await db.get(Agent, agent.id, populate_existing=True)
62
+ if current is None or current.disabled or current.token_hash != expected_hash:
63
+ return Verdict(status="deny", denied=list(required))
64
+ policies = (
65
+ await db.execute(select(Policy).execution_options(populate_existing=True).where(
66
+ (Policy.agent_id == agent.id) | Policy.agent_id.is_(None)))
67
+ ).scalars().all()
68
+ grants = (
69
+ await db.execute(select(Grant).execution_options(populate_existing=True).where(
70
+ Grant.agent_id == agent.id, Grant.revoked_at.is_(None)))
71
+ ).scalars().all()
72
+ resources = {r.path_prefix: r for r in (await db.execute(
73
+ select(Resource).execution_options(populate_existing=True))).scalars().all()}
74
+ request_ids = {g.request_id for g in grants if g.scope == "once" and g.request_id}
75
+ requests = {r.id: r for r in (await db.execute(
76
+ select(AccessRequest).where(AccessRequest.id.in_(request_ids)))).scalars().all()} if request_ids else {}
77
+
78
+ v, to_consume = decide(
79
+ agent, session_id, required, policies=policies, grants=grants, resources=resources,
80
+ requests=requests, at=at, operation=operation, discovery=discovery,
81
+ )
82
+ if v.allowed and consume and to_consume:
83
+ # Conditional updates serialize competing calls in the database, across workers too.
84
+ # Consume each grant once per operation, even if it covers multiple required paths.
85
+ for gid in sorted({g.id for g in to_consume}):
86
+ changed = await db.execute(update(Grant).where(
87
+ Grant.id == gid, Grant.revoked_at.is_(None), Grant.uses_remaining > 0,
88
+ (Grant.expires_at.is_(None)) | (Grant.expires_at > now()),
89
+ ).values(uses_remaining=Grant.uses_remaining - 1).execution_options(synchronize_session=False))
90
+ if changed.rowcount != 1:
91
+ await db.rollback()
92
+ await db.refresh(agent)
93
+ return Verdict(status="pending", missing=list(required))
94
+ v.consumed_grants.append(gid)
95
+ await db.commit()
96
+ return v
97
+
98
+
99
+ def decide(
100
+ agent: Agent,
101
+ session_id: str | None,
102
+ required: list[str],
103
+ *,
104
+ policies: list[Policy],
105
+ grants: list[Grant],
106
+ resources: dict[str, Resource],
107
+ requests: dict[str, AccessRequest],
108
+ at: datetime,
109
+ operation: dict | None = None,
110
+ discovery: bool = False,
111
+ ) -> tuple[Verdict, list[Grant]]:
112
+ """Pure decision shared by live enforcement and history previews.
113
+
114
+ Inputs are the current agent's applicable policies/grants and a resource snapshot.
115
+ The returned grant list is a consumption plan; only evaluate() can spend it.
116
+ """
117
+ if agent.disabled:
118
+ return Verdict(status="deny", denied=list(required)), []
119
+ v = Verdict(hazard=worst(operation.get("hazards")) if operation else None)
120
+ fresh_only = blocks(v.hazard)
121
+
122
+ def eligible(g: Grant, op: dict) -> bool:
123
+ req = requests.get(g.request_id)
124
+ if req and req.session_id and req.session_id != session_id:
125
+ return False
126
+ return discovery or operation_matches(grant_constraints(g, req), op)
127
+
128
+ to_consume: list[Grant] = []
129
+ for path in required:
130
+ rp = pat.resource_of(path)
131
+ res = resources.get(f"{rp[0]}/{rp[1]}") if rp else None
132
+ if res is None:
133
+ v.unknown.append(path)
134
+ continue
135
+ # Without an operation, rules bound to this target still apply; exact-operation rules do not.
136
+ op = operation if operation is not None else target_of(res)
137
+ deny_matches = pat.overlaps if "*" in path else pat.matches
138
+ allow_matches = pat.covers if "*" in path else pat.matches
139
+ if any(p.effect == "deny" and deny_matches(p.pattern, path)
140
+ and operation_matches(p.constraints, op) for p in policies):
141
+ v.denied.append(path)
142
+ continue
143
+ active = [g for g in grants if g.is_active(at, session_id)
144
+ and allow_matches(g.pattern, path) and eligible(g, op)
145
+ and (not fresh_only or (g.scope == "once" and binds_operation(grant_constraints(g, requests.get(g.request_id)))))]
146
+ if active:
147
+ # prefer grants that don't get used up, then the most specific one
148
+ active.sort(key=lambda g: (g.scope == "once", -pat.specificity(g.pattern)))
149
+ g = active[0]
150
+ v.matched[path] = f"grant:{g.id}"
151
+ v.bound[path] = constraint_label(grant_constraints(g, requests.get(g.request_id)))
152
+ if g.request_id and g.request_id not in v.request_ids:
153
+ v.request_ids.append(g.request_id)
154
+ if g.scope == "once":
155
+ to_consume.append(g)
156
+ continue
157
+ if fresh_only:
158
+ v.missing.append(path)
159
+ continue
160
+ allow = [p for p in policies if p.effect == "allow" and allow_matches(p.pattern, path)
161
+ and (discovery or operation_matches(p.constraints, op))]
162
+ if allow:
163
+ v.matched[path] = f"policy:{allow[0].id}"
164
+ v.bound[path] = constraint_label(allow[0].constraints)
165
+ if allow[0].request_id and allow[0].request_id not in v.request_ids:
166
+ v.request_ids.append(allow[0].request_id)
167
+ continue
168
+ if res.default_mode == "open":
169
+ v.matched[path] = "resource:open"
170
+ elif res.default_mode == "locked":
171
+ v.locked.append(path)
172
+ else:
173
+ v.missing.append(path)
174
+
175
+ if v.denied:
176
+ v.status = "deny"
177
+ elif v.unknown:
178
+ v.status = "unknown"
179
+ elif v.locked:
180
+ v.status = "locked"
181
+ elif v.missing:
182
+ v.status = "pending"
183
+ else:
184
+ v.status = "allow"
185
+ v.allowed = True
186
+ return v, to_consume
187
+
188
+
189
+ async def effective_access(db: AsyncSession, agent: Agent, session_id: str | None) -> list[dict]:
190
+ """What `access_list_resources` shows: per resource, the agent's current standing."""
191
+ at = now()
192
+ out = []
193
+ resources = (await db.execute(select(Resource).order_by(Resource.kind, Resource.name))).scalars().all()
194
+ policies = (
195
+ await db.execute(select(Policy).execution_options(populate_existing=True).where(
196
+ (Policy.agent_id == agent.id) | Policy.agent_id.is_(None)))
197
+ ).scalars().all()
198
+ grants = (
199
+ await db.execute(select(Grant).execution_options(populate_existing=True).where(
200
+ Grant.agent_id == agent.id, Grant.revoked_at.is_(None)))
201
+ ).scalars().all()
202
+ legacy_ids = {g.request_id for g in grants if g.scope == "once" and not g.constraints and g.request_id}
203
+ legacy_requests = {r.id: r for r in (await db.execute(select(AccessRequest).where(
204
+ AccessRequest.id.in_(legacy_ids)))).scalars().all()} if legacy_ids else {}
205
+ for r in resources:
206
+ if r.config.get("adapter") == "knowledge":
207
+ # Collection names/descriptions are knowledge metadata too. Discover only collections
208
+ # with an available tool; every actual call is still gated independently.
209
+ from .knowledge_mcp import OPERATIONS
210
+ visible = False
211
+ for tool in OPERATIONS:
212
+ verdict = await evaluate(db, agent, session_id, [f"mcp/{r.name}/tools/{tool}"],
213
+ consume=False, discovery=True)
214
+ if verdict.status not in ("deny", "locked", "unknown"):
215
+ visible = True
216
+ break
217
+ if not visible:
218
+ continue
219
+ prefix = r.path_prefix
220
+ entry = {
221
+ "resource": prefix,
222
+ "kind": r.kind,
223
+ "default": r.default_mode,
224
+ "description": r.description,
225
+ **({"argument_keys": list(r.config.get("arg_keys") or []),
226
+ "bundles": {n: [f"mcp/{r.name}/{e}" for e in es] for n, es in (r.config.get("bundles") or {}).items()}}
227
+ if r.kind == "mcp" else {}),
228
+ **({"models": llm.model_paths(r), "limits_per_lease": llm.limits_of(r.config),
229
+ "agent_budget": llm.agent_budget_of(r.config),
230
+ "budgets_enforced": llm.enforces_budgets(r.config.get("health")),
231
+ "note": "Ask for llm/<gateway>/model/<model> patterns with access_request; an approval is a timed "
232
+ "lease with a spend ceiling, never once or always."}
233
+ if r.kind == "llm" else {}),
234
+ "allow_rules": sorted({p.pattern for p in policies if p.effect == "allow" and not p.constraints
235
+ and _touches(p.pattern, prefix)}),
236
+ "deny_rules": sorted({p.pattern for p in policies if p.effect == "deny" and not p.constraints
237
+ and _touches(p.pattern, prefix)}),
238
+ "constrained_rules": [
239
+ {"pattern": p.pattern, "effect": p.effect, "constraints": p.constraints}
240
+ for p in policies if p.constraints and _touches(p.pattern, prefix)
241
+ ],
242
+ "grants": [
243
+ {
244
+ "pattern": g.pattern,
245
+ "scope": g.scope,
246
+ "expires_at": _iso(g.expires_at),
247
+ "uses_remaining": g.uses_remaining,
248
+ "constraints": grant_constraints(g, legacy_requests.get(g.request_id)),
249
+ }
250
+ for g in grants
251
+ if g.is_active(at, session_id) and _touches(g.pattern, prefix)
252
+ ],
253
+ }
254
+ out.append(entry)
255
+ return out
256
+
257
+
258
+ def _touches(pattern: str, prefix: str) -> bool:
259
+ p = pattern.split("/")
260
+ q = prefix.split("/")
261
+ return all(a in ("*", "**") or a == b for a, b in zip(p[:2], q, strict=False)) or p[0] == "**"
262
+
263
+
264
+ def _iso(d: datetime | None) -> str | None:
265
+ return d.isoformat() if d else None