tlgr-cli 2.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 (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/daemon/main.py ADDED
@@ -0,0 +1,161 @@
1
+ """The daemon entry point, in the order §6.1 requires.
2
+
3
+ The order is the design. `umask` before anything creates a file; the singleton
4
+ lock before any work, so two simultaneous starts cannot both proceed; the
5
+ permission audit before a session file is opened; the socket bound and
6
+ answering `ready: false` before the first account connect, so a CLI waiting to
7
+ start us learns we exist in milliseconds rather than in however long a
8
+ connect takes.
9
+
10
+ A second daemon exits **0**, not 1. Under launchd with
11
+ `KeepAlive.SuccessfulExit=false`, exiting non-zero means "restart me", so v1's
12
+ `sys.exit(1)` on "already running" produced an infinite respawn loop (COR-39).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import contextlib
20
+ import logging
21
+ import os
22
+ import signal
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from tlgr.core.config import load_app_config
27
+ from tlgr.core.errors import ConfigurationError
28
+ from tlgr.core.paths import TlgrPaths, require_safe_permissions
29
+ from tlgr.core.process import daemonize, setup_logging, write_pid
30
+ from tlgr.daemon.app import Daemon
31
+ from tlgr.daemon.singleton import FileLock, LockBusy
32
+
33
+ log = logging.getLogger("tlgr.daemon")
34
+
35
+ __all__ = ["main", "run"]
36
+
37
+
38
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
39
+ parser = argparse.ArgumentParser(prog="tlgr-daemon", description="the tlgr daemon")
40
+ parser.add_argument("--base", default="", help="tlgr home directory (default ~/.tlgr)")
41
+ parser.add_argument("--foreground", action="store_true", help="do not fork; log to stderr too")
42
+ parser.add_argument("--log-level", default="", help="debug | info | warning | error")
43
+ return parser.parse_args(argv)
44
+
45
+
46
+ async def run(daemon: Daemon) -> None:
47
+ """Install signal handlers and run until told to stop."""
48
+ loop = asyncio.get_running_loop()
49
+ # A task with no reference can be garbage-collected mid-flight, which is
50
+ # COR-41: a SIGHUP reload that vanished halfway through.
51
+ pending: set[asyncio.Task[None]] = set()
52
+
53
+ def request_stop() -> None:
54
+ log.info("shutdown requested")
55
+ daemon.request_shutdown()
56
+
57
+ def request_reload() -> None:
58
+ log.info("reload requested (SIGHUP)")
59
+ task = loop.create_task(_reload(daemon))
60
+ pending.add(task)
61
+ task.add_done_callback(pending.discard)
62
+
63
+ for sig in (signal.SIGTERM, signal.SIGINT):
64
+ with contextlib.suppress(NotImplementedError):
65
+ loop.add_signal_handler(sig, request_stop)
66
+ with contextlib.suppress(NotImplementedError, AttributeError):
67
+ loop.add_signal_handler(signal.SIGHUP, request_reload)
68
+
69
+ await daemon.run()
70
+
71
+
72
+ async def _reload(daemon: Daemon) -> None:
73
+ from tlgr.daemon.app import _reload as reload_config
74
+
75
+ try:
76
+ await reload_config(daemon, ["config", "jobs", "policy"])
77
+ except Exception as exc: # pragma: no cover - a bad config must not kill us
78
+ log.error("reload failed, keeping the running configuration: %s", exc)
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ args = _parse_args(argv)
83
+
84
+ # SEC-01: before anything creates a file. A daemon started from a shell
85
+ # with a permissive umask would otherwise write a world-readable session,
86
+ # log and socket, and fixing the modes afterwards leaves a window.
87
+ os.umask(0o077)
88
+
89
+ base = Path(args.base).expanduser() if args.base else None
90
+
91
+ # `TlgrPaths` refuses a home marked `.production`, and that refusal has to
92
+ # be an exit code rather than a traceback: two daemons on one home share
93
+ # session files, and Telegram treats a second client on the same auth key
94
+ # as a compromised session and revokes it. A development build started
95
+ # against somebody's live home does not degrade it, it breaks it.
96
+ try:
97
+ paths = TlgrPaths(base)
98
+ except ConfigurationError as exc:
99
+ print(f"tlgr daemon: {exc}", file=sys.stderr)
100
+ return 10
101
+
102
+ paths.ensure_base()
103
+
104
+ try:
105
+ config = load_app_config(paths.base)
106
+ except ConfigurationError as exc:
107
+ print(f"tlgr daemon: {exc}", file=sys.stderr)
108
+ return 10
109
+
110
+ lock = FileLock(paths.lock)
111
+ try:
112
+ lock.acquire()
113
+ except LockBusy as exc:
114
+ # Exit 0: this is the expected outcome of racing a start, and a
115
+ # non-zero exit tells launchd to respawn us forever (COR-39).
116
+ print(f"tlgr daemon: already running{f' (pid {exc.pid})' if exc.pid else ''}")
117
+ return 0
118
+
119
+ level = args.log_level or config.daemon.log_level
120
+ if not args.foreground:
121
+ daemonize(paths.base)
122
+ # The pid written before the fork belongs to a process that no longer
123
+ # exists; the lock survives the fork, its recorded pid must too.
124
+ lock.write_pid()
125
+ setup_logging(paths.base, level, foreground=args.foreground)
126
+ write_pid(paths.base)
127
+
128
+ try:
129
+ require_safe_permissions(paths.base)
130
+ except ConfigurationError as exc:
131
+ log.error("%s", exc)
132
+ print(f"tlgr daemon: {exc}", file=sys.stderr)
133
+ lock.release()
134
+ return 10
135
+
136
+ managed_by = _managed_by()
137
+ daemon = Daemon(paths.base, config=config, managed_by=managed_by)
138
+ log.info(
139
+ "daemon starting",
140
+ extra={"pid": os.getpid(), "version": __import__("tlgr").__version__},
141
+ )
142
+ try:
143
+ asyncio.run(run(daemon))
144
+ except KeyboardInterrupt: # pragma: no cover - interactive only
145
+ pass
146
+ finally:
147
+ lock.release()
148
+ return 0
149
+
150
+
151
+ def _managed_by() -> str:
152
+ """Whether a supervisor started us, which changes the idle policy (§6.12)."""
153
+ if os.environ.get("INVOCATION_ID"):
154
+ return "systemd"
155
+ if os.environ.get("XPC_SERVICE_NAME", "").startswith("dev.tlgr"):
156
+ return "launchd"
157
+ return ""
158
+
159
+
160
+ if __name__ == "__main__": # pragma: no cover
161
+ raise SystemExit(main())
@@ -0,0 +1,75 @@
1
+ """Who is on the other end of the socket (SEC-01).
2
+
3
+ The socket is 0600, which is the real control. This is defence in depth over
4
+ it, for two reasons: a umask can be changed by anything in the process tree
5
+ that started the daemon, and a refusal here produces an auditable log line
6
+ naming the peer's pid, which "connection refused by file permissions" does
7
+ not.
8
+
9
+ The two platforms disagree about how to ask:
10
+
11
+ * **Linux** — `getsockopt(SOL_SOCKET, SO_PEERCRED)` returns `struct ucred`,
12
+ three native ints: pid, uid, gid.
13
+ * **macOS/BSD** — `getsockopt(SOL_LOCAL, LOCAL_PEERCRED)` returns
14
+ `struct xucred`: version (u32), uid (u32), ngroups (short), groups[16].
15
+ There is no pid in it. `SOL_LOCAL` is 0 and `LOCAL_PEERCRED` is 1; the
16
+ layout was verified on Darwin 25.6.
17
+
18
+ Where neither works, `peer_of()` returns `None` and the caller falls back to
19
+ the shared token (§8.2) — which is why `require_token` exists as a config key
20
+ rather than a hard-coded platform test.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hmac
26
+ import os
27
+ import socket
28
+ import struct
29
+ import sys
30
+ from dataclasses import dataclass
31
+
32
+ __all__ = ["Peer", "peer_of", "token_matches"]
33
+
34
+ _SO_PEERCRED = getattr(socket, "SO_PEERCRED", 17)
35
+ _SOL_LOCAL = 0
36
+ _LOCAL_PEERCRED = 1
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Peer:
41
+ uid: int
42
+ pid: int | None = None
43
+ gid: int | None = None
44
+
45
+
46
+ def peer_of(sock: socket.socket | None) -> Peer | None:
47
+ """The credentials of the process on the other end, or None if unknown."""
48
+ if sock is None:
49
+ return None
50
+ try:
51
+ if sys.platform.startswith("linux"):
52
+ raw = sock.getsockopt(socket.SOL_SOCKET, _SO_PEERCRED, struct.calcsize("3i"))
53
+ pid, uid, gid = struct.unpack("3i", raw)
54
+ return Peer(uid=uid, pid=pid, gid=gid)
55
+ if sys.platform == "darwin" or "bsd" in sys.platform:
56
+ raw = sock.getsockopt(_SOL_LOCAL, _LOCAL_PEERCRED, 4 + 4 + 2 + 4 * 16)
57
+ # cr_version, cr_uid — the rest of xucred is groups we do not need.
58
+ _version, uid = struct.unpack_from("=II", raw, 0)
59
+ return Peer(uid=uid)
60
+ except (OSError, struct.error):
61
+ return None
62
+ return None
63
+
64
+
65
+ def token_matches(supplied: str | None, expected: str | None) -> bool:
66
+ """Constant-time comparison; both sides absent is *not* a match."""
67
+ if not expected:
68
+ return False
69
+ if not supplied:
70
+ return False
71
+ return hmac.compare_digest(supplied.strip(), expected.strip())
72
+
73
+
74
+ def current_uid() -> int:
75
+ return os.getuid()
tlgr/daemon/policy.py ADDED
@@ -0,0 +1,113 @@
1
+ """The operation allowlist, enforced where it cannot be bypassed (SEC-04).
2
+
3
+ Three properties, each of which v1 lacked:
4
+
5
+ * **matched by canonical id.** `--enable-commands message.send` also permits
6
+ the `send` alias and the legacy `message send` path, because every name is
7
+ canonicalised through the registry before it is compared. v1 matched the
8
+ literal command path, so an allowlist written against one spelling let the
9
+ other through — or, more often, blocked a command the operator thought they
10
+ had allowed.
11
+ * **enforced in the daemon.** The CLI checks too, for a fast, local error, but
12
+ the CLI flag is under the agent's control. The daemon's copy is the one with
13
+ teeth, and it comes from `config.toml` or a token-bound policy file.
14
+ * **`deny` beats `allow`,** and behaviour classes (`*:mutating`,
15
+ `*:destructive`, `*:read`) can be denied wholesale, so "this agent may read
16
+ anything and change nothing" is one entry instead of five hundred.
17
+
18
+ This is a usability guard, not a sandbox: anything that can open the socket can
19
+ also read the session file. §8.3 says so, and `SECURITY.md` repeats it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import TYPE_CHECKING
26
+
27
+ from tlgr.core.errors import PermissionError_
28
+
29
+ if TYPE_CHECKING: # pragma: no cover
30
+ from tlgr.ops._spec import OperationSpec
31
+
32
+ __all__ = ["Policy", "PolicyDecision"]
33
+
34
+ #: Behaviour classes usable in place of an id.
35
+ _CLASSES = {"read", "mutating", "destructive", "stream", "local"}
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class PolicyDecision:
40
+ allowed: bool
41
+ reason: str = ""
42
+
43
+
44
+ @dataclass
45
+ class Policy:
46
+ """An allow/deny pair over canonical operation ids."""
47
+
48
+ allow: list[str] = field(default_factory=lambda: ["*"])
49
+ deny: list[str] = field(default_factory=list)
50
+
51
+ @classmethod
52
+ def from_config(cls, allow: list[str] | None, deny: list[str] | None) -> Policy:
53
+ return cls(allow=list(allow or ["*"]), deny=list(deny or []))
54
+
55
+ @classmethod
56
+ def parse(cls, spec: str) -> Policy:
57
+ """Parse a `--enable-commands`-style string into an allowlist."""
58
+ entries = [e.strip() for e in spec.replace(" ", ",").split(",") if e.strip()]
59
+ return cls(allow=entries or ["*"])
60
+
61
+ # -- matching ----------------------------------------------------------
62
+
63
+ def _canonical(self, name: str) -> str:
64
+ from tlgr.registry import ALIASES
65
+
66
+ key = name.strip().lower().replace(" ", ".")
67
+ return ALIASES.get(key, key)
68
+
69
+ def _classes_of(self, spec: OperationSpec | None) -> set[str]:
70
+ if spec is None:
71
+ return set()
72
+ classes = {"stream"} if spec.stream else set()
73
+ if spec.destructive:
74
+ classes |= {"mutating", "destructive"}
75
+ elif spec.mutating:
76
+ classes.add("mutating")
77
+ else:
78
+ classes.add("read")
79
+ if spec.surface.value == "local":
80
+ classes.add("local")
81
+ return classes
82
+
83
+ def _matches(self, entry: str, op_id: str, classes: set[str]) -> bool:
84
+ entry = entry.strip().lower()
85
+ if not entry:
86
+ return False
87
+ if entry in ("*", "all"):
88
+ return True
89
+ if entry.startswith("*:"):
90
+ return entry[2:] in classes and entry[2:] in _CLASSES
91
+ if entry.endswith(".*"):
92
+ return op_id.startswith(entry[:-1])
93
+ resolved = self._canonical(entry)
94
+ return resolved == op_id or op_id.startswith(f"{resolved}.")
95
+
96
+ def check(self, op_id: str, spec: OperationSpec | None = None) -> PolicyDecision:
97
+ """Is *op_id* permitted? `deny` wins over `allow`, always."""
98
+ canonical = self._canonical(op_id)
99
+ classes = self._classes_of(spec)
100
+ for entry in self.deny:
101
+ if self._matches(entry, canonical, classes):
102
+ return PolicyDecision(False, f"denied by policy entry {entry!r}")
103
+ for entry in self.allow:
104
+ if self._matches(entry, canonical, classes):
105
+ return PolicyDecision(True)
106
+ return PolicyDecision(False, "not in the policy allowlist")
107
+
108
+ def enforce(self, op_id: str, spec: OperationSpec | None = None) -> None:
109
+ decision = self.check(op_id, spec)
110
+ if not decision.allowed:
111
+ raise PermissionError_(
112
+ f"operation {op_id!r} is not enabled: {decision.reason}",
113
+ )