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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
forgecli/sdk/manifest.py
ADDED
|
@@ -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
|
+
]
|
forgecli/sdk/sandbox.py
ADDED
|
@@ -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
|
+
]
|