forgeoptimizer 0.1.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.
Files changed (159) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,395 @@
1
+ """Structured plugin metadata.
2
+
3
+ A :class:`PluginManifest` is the canonical description of a plugin:
4
+ its identity (name, version), its dependencies on other plugins and
5
+ on the host, the entry points it registers, the permissions it
6
+ asks for, and the optional compatibility constraints. Manifests
7
+ are serialised as TOML on disk (next to the plugin's source) and
8
+ parsed back into the same shape.
9
+
10
+ See :file:`PLUGINS.md` for the full schema and authoring guide.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import tomllib
17
+ from collections.abc import Iterable
18
+ from dataclasses import dataclass, field
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from forgecli.sdk.version import Requirement, Version, VersionParseError
24
+
25
+
26
+ class Permission(str, Enum):
27
+ """The kinds of resources a plugin can ask to use.
28
+
29
+ Permissions are *advisory*: the SDK tracks what each plugin
30
+ declares, and surfaces the list in ``forge plugin doctor`` and
31
+ in the README. The core does not currently enforce them
32
+ sandbox-style, but the manifest is the source of truth so
33
+ future versions can add enforcement without changing the
34
+ manifest schema.
35
+ """
36
+
37
+ NETWORK = "network" # outbound HTTP / sockets
38
+ SUBPROCESS = "subprocess" # spawn child processes
39
+ FILESYSTEM = "filesystem" # read/write outside the plugin dir
40
+ SECRETS = "secrets" # read env vars / credential stores
41
+ EXEC = "exec" # eval / exec dynamic code
42
+ SHELL = "shell" # shell=True subprocess invocations
43
+ NETWORK_LISTEN = "network-listen" # open a server socket
44
+ LIFECYCLE = "lifecycle" # install / enable / disable / uninstall
45
+
46
+
47
+ # Reserved entry-point groups. A plugin declares one or more of these
48
+ # in its manifest; the SDK's loader uses the values to find the
49
+ # callable to import at enable-time.
50
+ class EntryPointKind(str, Enum):
51
+ PROVIDER = "provider"
52
+ REPOSITORY_ANALYZER = "repository-analyzer"
53
+ CONTEXT_OPTIMIZER = "context-optimizer"
54
+ CODE_GENERATOR = "code-generator"
55
+ TEST_RUNNER = "test-runner"
56
+ GIT_PROVIDER = "git-provider"
57
+ DOCS_GENERATOR = "docs-generator"
58
+ DEPLOYMENT_PROVIDER = "deployment-provider"
59
+ OBSERVABILITY = "observability"
60
+ NOTIFICATION = "notification"
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class EntryPoint:
65
+ """A single ``[tool.forgecli.plugins.<name>]`` entry-point declaration."""
66
+
67
+ kind: EntryPointKind
68
+ name: str
69
+ reference: str # ``"module:attr"`` or ``"package.module:attr"``
70
+
71
+ def __str__(self) -> str:
72
+ return f"{self.kind.value}={self.name} -> {self.reference}"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Compatibility:
77
+ """The host-version range a plugin is compatible with."""
78
+
79
+ min_sdk: Version | None = None
80
+ max_sdk: Version | None = None
81
+ python: str | None = None # e.g. ">=3.12,<3.14"
82
+ os_targets: tuple[str, ...] = () # e.g. ("linux", "macos", "windows")
83
+
84
+ def matches_host(self, sdk_version: Version, python_version: str, os_name: str) -> bool:
85
+ if self.min_sdk is not None and sdk_version < self.min_sdk:
86
+ return False
87
+ if self.max_sdk is not None and sdk_version > self.max_sdk:
88
+ return False
89
+ if self.python is not None and not _python_matches(self.python, python_version):
90
+ return False
91
+ return not (bool(self.os_targets) and os_name not in self.os_targets)
92
+
93
+
94
+ def _python_matches(spec: str, version: str) -> bool:
95
+ """Tiny PEP-440-ish subset for the ``compatibility.python`` field."""
96
+ try:
97
+ req = Requirement.parse("python", spec)
98
+ except ValueError:
99
+ return True
100
+ try:
101
+ return req.matches(Version.parse(version))
102
+ except VersionParseError:
103
+ return True
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class PluginManifest:
108
+ """The structured description of a single plugin.
109
+
110
+ Manifests are loaded from ``<plugin>/forgecli-plugin.toml``.
111
+ """
112
+
113
+ name: str
114
+ version: Version
115
+ summary: str
116
+ description: str = ""
117
+ authors: tuple[str, ...] = ()
118
+ license: str = ""
119
+ homepage: str = ""
120
+ repository: str = ""
121
+ dependencies: tuple[Requirement, ...] = ()
122
+ permissions: tuple[Permission, ...] = ()
123
+ entry_points: tuple[EntryPoint, ...] = ()
124
+ compatibility: Compatibility = field(default_factory=Compatibility)
125
+ config_schema: dict[str, Any] = field(default_factory=dict)
126
+ source: Path | None = None
127
+
128
+ # ------------------------------------------------------------------
129
+ # Serialisation
130
+ # ------------------------------------------------------------------
131
+
132
+ @classmethod
133
+ def load(cls, path: Path) -> PluginManifest:
134
+ """Read ``path`` and return a :class:`PluginManifest`."""
135
+ path = Path(path)
136
+ if not path.exists():
137
+ raise FileNotFoundError(f"manifest not found: {path}")
138
+ try:
139
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
140
+ except (OSError, tomllib.TOMLDecodeError) as exc:
141
+ raise ValueError(f"could not parse manifest at {path}: {exc}") from exc
142
+ return cls._from_dict(data, source=path)
143
+
144
+ def dump(self) -> str:
145
+ """Serialise the manifest as TOML."""
146
+ try:
147
+ import tomli_w # type: ignore[import-not-found,import-untyped]
148
+
149
+ return tomli_w.dumps(self._to_dict())
150
+ except ImportError:
151
+ # Fall back to a hand-rolled minimal TOML writer. Plugins
152
+ # typically have tomli_w installed; this is for tests.
153
+ return _simple_toml_dump(self._to_dict())
154
+
155
+ @classmethod
156
+ def _from_dict(cls, data: dict[str, Any], *, source: Path | None) -> PluginManifest:
157
+ if not isinstance(data, dict):
158
+ raise ValueError("manifest root must be a table")
159
+ plugin = data.get("plugin") or data
160
+ if not isinstance(plugin, dict):
161
+ raise ValueError("manifest must contain a [plugin] table")
162
+
163
+ name = _required_str(plugin, "name", source)
164
+ version_str = _required_str(plugin, "version", source)
165
+ try:
166
+ version = Version.parse(version_str)
167
+ except VersionParseError as exc:
168
+ raise ValueError(f"manifest at {source}: invalid version {version_str!r}") from exc
169
+ summary = _required_str(plugin, "summary", source)
170
+ description = str(plugin.get("description", ""))
171
+ authors = tuple(_as_str_list(plugin.get("authors")))
172
+ license_name = str(plugin.get("license", ""))
173
+ homepage = str(plugin.get("homepage", ""))
174
+ repository = str(plugin.get("repository", ""))
175
+
176
+ deps = tuple(
177
+ Requirement.parse(str(name), str(spec))
178
+ for name, spec in (plugin.get("dependencies") or {}).items()
179
+ )
180
+ permissions = tuple(Permission(p) for p in _as_str_list(plugin.get("permissions")))
181
+ entry_points = tuple(_parse_entry_points(plugin.get("entry_points") or {}))
182
+
183
+ comp_data = plugin.get("compatibility") or {}
184
+ compatibility = _parse_compatibility(comp_data)
185
+
186
+ config_schema = dict(plugin.get("config_schema") or {})
187
+
188
+ return cls(
189
+ name=name,
190
+ version=version,
191
+ summary=summary,
192
+ description=description,
193
+ authors=authors,
194
+ license=license_name,
195
+ homepage=homepage,
196
+ repository=repository,
197
+ dependencies=deps,
198
+ permissions=permissions,
199
+ entry_points=entry_points,
200
+ compatibility=compatibility,
201
+ config_schema=config_schema,
202
+ source=source,
203
+ )
204
+
205
+ def _to_dict(self) -> dict[str, Any]:
206
+ out: dict[str, Any] = {
207
+ "name": self.name,
208
+ "version": str(self.version),
209
+ "summary": self.summary,
210
+ }
211
+ if self.description:
212
+ out["description"] = self.description
213
+ if self.authors:
214
+ out["authors"] = list(self.authors)
215
+ if self.license:
216
+ out["license"] = self.license
217
+ if self.homepage:
218
+ out["homepage"] = self.homepage
219
+ if self.repository:
220
+ out["repository"] = self.repository
221
+ if self.dependencies:
222
+ out["dependencies"] = {r.name: str(r) for r in self.dependencies}
223
+ if self.permissions:
224
+ out["permissions"] = [p.value for p in self.permissions]
225
+ if self.entry_points:
226
+ out["entry_points"] = {
227
+ ep.kind.value: {ep.name: ep.reference} for ep in self.entry_points
228
+ }
229
+ comp: dict[str, Any] = {}
230
+ if self.compatibility.min_sdk is not None:
231
+ comp["min_sdk"] = str(self.compatibility.min_sdk)
232
+ if self.compatibility.max_sdk is not None:
233
+ comp["max_sdk"] = str(self.compatibility.max_sdk)
234
+ if self.compatibility.python:
235
+ comp["python"] = self.compatibility.python
236
+ if self.compatibility.os_targets:
237
+ comp["os"] = list(self.compatibility.os_targets)
238
+ if comp:
239
+ out["compatibility"] = comp
240
+ if self.config_schema:
241
+ out["config_schema"] = self.config_schema
242
+ return {"plugin": out}
243
+
244
+ def to_dict(self) -> dict[str, Any]:
245
+ """Return a JSON-serializable view of the manifest."""
246
+ return self._to_dict()
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Parsing helpers
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _required_str(data: dict[str, Any], key: str, source: Path | None) -> str:
255
+ value = data.get(key)
256
+ if not isinstance(value, str) or not value.strip():
257
+ location = f" in {source}" if source else ""
258
+ raise ValueError(f"manifest{location}: missing required string field {key!r}")
259
+ return value.strip()
260
+
261
+
262
+ def _as_str_list(value: Any) -> list[str]:
263
+ if value is None:
264
+ return []
265
+ if isinstance(value, str):
266
+ return [s.strip() for s in value.split(",") if s.strip()]
267
+ if isinstance(value, list):
268
+ return [str(v).strip() for v in value if str(v).strip()]
269
+ return [str(value).strip()]
270
+
271
+
272
+ def _parse_entry_points(data: dict[str, Any]) -> Iterable[EntryPoint]:
273
+ for kind_name, entries in data.items():
274
+ try:
275
+ kind = EntryPointKind(kind_name)
276
+ except ValueError:
277
+ continue
278
+ if isinstance(entries, dict):
279
+ for name, reference in entries.items():
280
+ yield EntryPoint(kind=kind, name=str(name), reference=str(reference))
281
+ elif isinstance(entries, list):
282
+ for entry in entries:
283
+ if not isinstance(entry, dict):
284
+ continue
285
+ name = str(entry.get("name", ""))
286
+ reference = str(entry.get("reference", ""))
287
+ if name and reference:
288
+ yield EntryPoint(kind=kind, name=name, reference=reference)
289
+
290
+
291
+ def _parse_compatibility(data: dict[str, Any]) -> Compatibility:
292
+ if not isinstance(data, dict):
293
+ return Compatibility()
294
+ min_sdk = _maybe_version(data.get("min_sdk"))
295
+ max_sdk = _maybe_version(data.get("max_sdk"))
296
+ python = str(data["python"]) if "python" in data else None
297
+ os_targets = tuple(_as_str_list(data.get("os")))
298
+ return Compatibility(
299
+ min_sdk=min_sdk,
300
+ max_sdk=max_sdk,
301
+ python=python,
302
+ os_targets=os_targets,
303
+ )
304
+
305
+
306
+ def _maybe_version(value: Any) -> Version | None:
307
+ if not value:
308
+ return None
309
+ try:
310
+ return Version.parse(str(value))
311
+ except VersionParseError:
312
+ return None
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Minimal TOML dumper (fallback when tomli_w is unavailable)
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ def _simple_toml_dump(data: dict[str, Any]) -> str:
321
+ """A very small TOML writer used when tomli_w is not installed.
322
+
323
+ Supports the subset we emit: tables, dotted keys, lists, strings,
324
+ numbers, and booleans. Not a general TOML writer; the
325
+ ``tomli_w`` dependency is recommended for plugin authors.
326
+ """
327
+ out: list[str] = []
328
+ # First, write the top-level non-table values.
329
+ inline: list[str] = []
330
+ tables: dict[str, dict[str, Any]] = {}
331
+ for key, value in data.items():
332
+ if isinstance(value, dict):
333
+ tables[key] = value
334
+ else:
335
+ inline.append(f"{key} = {_toml_value(value)}")
336
+ if inline:
337
+ out.extend(inline)
338
+ # Then, write each sub-table.
339
+ for name, table in tables.items():
340
+ if out:
341
+ out.append("")
342
+ out.append(f"[{name}]")
343
+ # Nested tables, dotted keys, etc.
344
+ for key, value in table.items():
345
+ if isinstance(value, dict):
346
+ # Emit as a nested table.
347
+ out.append("")
348
+ out.append(f"[{name}.{key}]")
349
+ for k2, v2 in value.items():
350
+ out.append(f"{k2} = {_toml_value(v2)}")
351
+ else:
352
+ out.append(f"{key} = {_toml_value(value)}")
353
+ return "\n".join(out) + "\n"
354
+
355
+
356
+ def _toml_value(value: Any) -> str:
357
+ """Render a single TOML scalar / list (best-effort)."""
358
+ if isinstance(value, bool):
359
+ return "true" if value else "false"
360
+ if isinstance(value, (int, float)):
361
+ return str(value)
362
+ if isinstance(value, str):
363
+ # Escape backslashes and double quotes.
364
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
365
+ return f'"{escaped}"'
366
+ if isinstance(value, (list, tuple)):
367
+ return "[" + ", ".join(_toml_value(item) for item in value) + "]"
368
+ if isinstance(value, dict):
369
+ # Inline table: { key = "value", key2 = 42 }
370
+ inner = ", ".join(f"{k} = {_toml_value(v)}" for k, v in value.items())
371
+ return f"{{ {inner} }}"
372
+ raise ValueError(f"cannot serialise value of type {type(value).__name__}")
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Identifier helpers
377
+ # ---------------------------------------------------------------------------
378
+
379
+
380
+ _NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{1,63}$")
381
+
382
+
383
+ def is_valid_plugin_name(name: str) -> bool:
384
+ """Return True if ``name`` is a valid plugin identifier."""
385
+ return bool(_NAME_RE.match(name))
386
+
387
+
388
+ __all__ = [
389
+ "Compatibility",
390
+ "EntryPoint",
391
+ "EntryPointKind",
392
+ "Permission",
393
+ "PluginManifest",
394
+ "is_valid_plugin_name",
395
+ ]
@@ -0,0 +1,247 @@
1
+ """Plugin sandboxing.
2
+
3
+ ForgeCLI does *not* run untrusted plugin code in a separate
4
+ sandboxed process by default — the plugin ecosystem is meant to
5
+ be trusted-but-scoped. This module provides two layers of
6
+ protection:
7
+
8
+ * :class:`ScopedBuiltins` — a stripped-down :data:`builtins`
9
+ dict that does not expose :func:`eval`, :func:`exec`,
10
+ :func:`compile`, or :func:`__import__`. Plugins that need to
11
+ import other plugins must do so at enable-time, not lazily.
12
+ * :class:`Sandbox` — a context manager that swaps the host's
13
+ :data:`builtins` for the stripped version while a callback runs.
14
+ The original builtins are restored on exit.
15
+
16
+ Plugins that request the :attr:`Permission.EXEC` permission bypass
17
+ the sandbox (their callbacks run with full builtins). Other
18
+ plugins run sandboxed by default.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import builtins as _builtins
24
+ import contextlib
25
+ from collections.abc import Callable, Iterator
26
+ from typing import Any
27
+
28
+ from forgecli.sdk.manifest import Permission
29
+
30
+ # Names that are always available; everything else is dropped.
31
+ _ALLOWED_BUILTIN_NAMES: frozenset[str] = frozenset(
32
+ {
33
+ # Core types
34
+ "object",
35
+ "type",
36
+ "super",
37
+ "property",
38
+ "classmethod",
39
+ "staticmethod",
40
+ "isinstance",
41
+ "issubclass",
42
+ "callable",
43
+ "id",
44
+ "hash",
45
+ "repr",
46
+ "str",
47
+ "int",
48
+ "float",
49
+ "complex",
50
+ "bool",
51
+ "list",
52
+ "tuple",
53
+ "set",
54
+ "frozenset",
55
+ "dict",
56
+ "bytes",
57
+ "bytearray",
58
+ "memoryview",
59
+ "range",
60
+ "slice",
61
+ "enumerate",
62
+ "zip",
63
+ "map",
64
+ "filter",
65
+ "reversed",
66
+ "iter",
67
+ "next",
68
+ "len",
69
+ "min",
70
+ "max",
71
+ "sum",
72
+ "abs",
73
+ "round",
74
+ "pow",
75
+ "divmod",
76
+ "all",
77
+ "any",
78
+ "sorted",
79
+ # Printing / I/O (controlled)
80
+ "print",
81
+ "open",
82
+ "input",
83
+ # Exceptions
84
+ "BaseException",
85
+ "Exception",
86
+ "ValueError",
87
+ "TypeError",
88
+ "KeyError",
89
+ "IndexError",
90
+ "StopIteration",
91
+ "RuntimeError",
92
+ "ImportError",
93
+ "AttributeError",
94
+ "NotImplementedError",
95
+ # Names commonly used in tests
96
+ "__name__",
97
+ "__doc__",
98
+ "__file__",
99
+ "__package__",
100
+ # Async
101
+ "asyncio",
102
+ }
103
+ )
104
+
105
+
106
+ # Names that the sandbox always *removes*.
107
+ _FORBIDDEN_BUILTIN_NAMES: frozenset[str] = frozenset(
108
+ {
109
+ "eval",
110
+ "exec",
111
+ "compile",
112
+ "__import__",
113
+ "globals",
114
+ "locals",
115
+ "vars",
116
+ "input",
117
+ "breakpoint",
118
+ }
119
+ )
120
+
121
+
122
+ class ScopedBuiltins:
123
+ """A pre-built :data:`builtins` dict for sandboxed callbacks.
124
+
125
+ The ``strict`` mode drops everything that is not in
126
+ :data:`_ALLOWED_BUILTIN_NAMES` *and* removes the always-forbidden
127
+ names. The ``relaxed`` mode keeps most builtins but still
128
+ removes the always-forbidden ones.
129
+ """
130
+
131
+ def __init__(self, *, strict: bool = False) -> None:
132
+ self._strict = strict
133
+ self._table: dict[str, Any] = {}
134
+ self._build()
135
+
136
+ def _build(self) -> None:
137
+ if self._strict:
138
+ self._table = {
139
+ name: getattr(_builtins, name)
140
+ for name in _ALLOWED_BUILTIN_NAMES
141
+ if hasattr(_builtins, name)
142
+ }
143
+ else:
144
+ # Keep most things, but always drop the forbidden ones.
145
+ self._table = dict(vars(_builtins))
146
+ for name in _FORBIDDEN_BUILTIN_NAMES:
147
+ self._table.pop(name, None)
148
+
149
+ @property
150
+ def table(self) -> dict[str, Any]:
151
+ """Return a copy of the scoped builtins table."""
152
+ return dict(self._table)
153
+
154
+
155
+ class Sandbox:
156
+ """Swap the host's :data:`builtins` for a scoped table inside ``with``.
157
+
158
+ Example::
159
+
160
+ with Sandbox(plugin_permissions=plugin.manifest.permissions):
161
+ plugin.callback(...)
162
+
163
+ Plugins that request :attr:`Permission.EXEC` are run with the
164
+ full builtins; everything else runs sandboxed.
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ *,
170
+ plugin_permissions: tuple[Permission, ...] = (),
171
+ strict: bool = False,
172
+ ) -> None:
173
+ self._scoped = ScopedBuiltins(strict=strict)
174
+ self._plugin_permissions = tuple(plugin_permissions)
175
+ self._original_builtins: dict[str, Any] | None = None
176
+
177
+ def __enter__(self) -> None:
178
+ if Permission.EXEC in self._plugin_permissions:
179
+ # Plugin asked for exec; give it the full power.
180
+ return
181
+ # Snapshot the *set* of attribute names *and* their values
182
+ # before we mutate anything. We use ``dir(builtins)`` to
183
+ # enumerate names because the scoped table may have
184
+ # stripped names that ``vars`` depends on.
185
+ self._original_builtins = {name: getattr(_builtins, name) for name in dir(_builtins)}
186
+ # Replace the host builtins module attributes with the
187
+ # scoped table for the duration of the block.
188
+ for name in list(self._original_builtins.keys()):
189
+ if name in self._scoped.table:
190
+ setattr(_builtins, name, self._scoped.table[name])
191
+ else:
192
+ with contextlib.suppress(AttributeError):
193
+ delattr(_builtins, name)
194
+
195
+ def __exit__(self, *exc: Any) -> None:
196
+ if self._original_builtins is None:
197
+ return
198
+ # Remove any names that exist now but did not exist when we
199
+ # entered. We use ``dir`` rather than ``vars`` to enumerate,
200
+ # because ``vars`` itself may have been stripped from the
201
+ # sandbox.
202
+ original = self._original_builtins
203
+ for name in list(dir(_builtins)):
204
+ if name not in original:
205
+ with contextlib.suppress(AttributeError):
206
+ delattr(_builtins, name)
207
+ # Restore every original name. Even if a name was removed
208
+ # during the block, ``__import__`` is back in the table.
209
+ for name, value in original.items():
210
+ with contextlib.suppress(AttributeError):
211
+ setattr(_builtins, name, value)
212
+ self._original_builtins = None
213
+
214
+
215
+ @contextlib.contextmanager
216
+ def sandbox(
217
+ *,
218
+ plugin_permissions: tuple[Permission, ...] = (),
219
+ strict: bool = False,
220
+ ) -> Iterator[None]:
221
+ """Functional form of :class:`Sandbox`."""
222
+ sb = Sandbox(plugin_permissions=plugin_permissions, strict=strict)
223
+ sb.__enter__()
224
+ try:
225
+ yield
226
+ finally:
227
+ sb.__exit__(None, None, None)
228
+
229
+
230
+ def run_sandboxed(
231
+ callback: Callable[..., Any],
232
+ *args: Any,
233
+ plugin_permissions: tuple[Permission, ...] = (),
234
+ strict: bool = False,
235
+ **kwargs: Any,
236
+ ) -> Any:
237
+ """Run ``callback(*args, **kwargs)`` inside a sandbox."""
238
+ with sandbox(plugin_permissions=plugin_permissions, strict=strict):
239
+ return callback(*args, **kwargs)
240
+
241
+
242
+ __all__ = [
243
+ "Sandbox",
244
+ "ScopedBuiltins",
245
+ "run_sandboxed",
246
+ "sandbox",
247
+ ]