Skip to content

Commit 53c0c4d

Browse files
committed
Add external plugin package verification
1 parent 0ac233e commit 53c0c4d

9 files changed

Lines changed: 241 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,13 +262,14 @@ automax plugins audit
262262
`plugins coverage` renders a builtin/external plugin matrix with category,
263263
remote-session, check-mode, dry-run, manual preview, diff preview and tool
264264
requirement coverage. `--strict` turns metadata and preview coverage failures
265-
into a release gate. External plugin SDK commands create, validate and package
265+
into a release gate. External plugin SDK commands create, validate, package and verify
266266
plugin sources before they are loaded with `--plugin-path`:
267267

268268
```bash
269269
automax plugins init company.echo --output ./plugins
270270
automax plugins check ./plugins
271271
automax plugins package ./plugins --output dist/company-plugins.zip
272+
automax plugins verify-package dist/company-plugins.zip
272273
automax plugins describe company.echo --plugin-path ./plugins
273274
```
274275

docs/concepts/plugin-system.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,4 @@ The core execution engine is independent from the builtin plugin set. Future
5959
external plugins can be registered without changing job orchestration, state
6060
storage or SSH connection handling.
6161

62-
External plugin sources can be created with `automax plugins init`, validated with `automax plugins check` and packaged with `automax plugins package`.
62+
External plugin sources can be created with `automax plugins init`, validated with `automax plugins check`, packaged with `automax plugins package` and verified with `automax plugins verify-package` before loading.

docs/guides/creating-plugins.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,17 @@ jobs or release pipelines:
8484
automax plugins init company.echo --output ./plugins
8585
automax plugins check ./plugins
8686
automax plugins package ./plugins --output dist/company-plugins.zip
87+
automax plugins verify-package dist/company-plugins.zip
8788
automax plugins describe company.echo --plugin-path ./plugins
8889
```
8990

9091
`plugins init` writes a minimal plugin class with canonical metadata, parameter
9192
schema, examples and result fields. `plugins check` loads the external path
9293
without builtin plugins and fails when the metadata contract is incomplete.
9394
`plugins package` runs the same validation before producing a ZIP archive with
94-
the plugin sources and `automax-plugin.json` manifest.
95+
the plugin sources, `automax-plugin.json`, Automax compatibility metadata and
96+
per-file SHA-256 checksums. `plugins verify-package` validates that manifest and
97+
checksums before a package is copied into a controlled plugin directory.
9598

9699
The packaged plugin can be unpacked into a controlled plugin directory and
97100
loaded with the existing `--plugin-path` option used by `run`, `validate`,

docs/reference/cli.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,13 +214,18 @@ automax plugins init company.echo --output ./plugins
214214
automax plugins check ./plugins
215215
automax plugins check ./plugins --format=json
216216
automax plugins package ./plugins --output dist/company-plugins.zip
217+
automax plugins package ./plugins --output dist/company-plugins.zip --automax-requires '>=0.1.0'
218+
automax plugins verify-package dist/company-plugins.zip
219+
automax plugins verify-package dist/company-plugins.zip --format=json
217220
automax plugins describe company.echo --plugin-path ./plugins
218221
```
219222

220223
`plugins check` loads only the external plugin path and validates the public
221224
metadata contract: canonical name, description, category, parameter schemas,
222225
examples and result fields. `plugins package` runs the same check before writing
223-
a ZIP archive with the plugin sources and `automax-plugin.json` manifest.
226+
a ZIP archive with the plugin sources, `automax-plugin.json`, Automax
227+
compatibility metadata and per-file SHA-256 checksums. `plugins verify-package`
228+
checks the manifest, canonical plugin names, file list, sizes and checksums.
224229

225230
## Manual commands
226231

docs/reference/future-work.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ JSONL stream for external integrations:
9191
This could support CI summaries, dashboards, notifications or external audit
9292
pipelines without coupling those integrations to SQLite internals.
9393

94+
## External plugin distribution
95+
96+
External plugin ZIP packages include Automax compatibility metadata and per-file
97+
SHA-256 checksums, and can be verified with `automax plugins verify-package`.
98+
Future distribution work should focus on real package repositories and optional
99+
signature workflows, not on changing the source-loading model.
100+
94101
## Deploy plugins
95102

96103
Automax already has the primitives for release-style workflows:

src/automax/cli/cli.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
create_plugin_skeleton,
4444
package_external_plugin_path,
4545
render_plugin_check_text,
46+
render_plugin_package_verify_text,
47+
verify_external_plugin_package,
4648
)
4749

4850

@@ -1867,15 +1869,45 @@ def check_plugin(path: str, output_format: str) -> None:
18671869
@click.argument("path", type=click.Path(exists=True))
18681870
@click.option("--output", "output_path", required=True, type=click.Path(dir_okay=False), help="ZIP package path.")
18691871
@click.option("--force", is_flag=True, help="Overwrite the package if it already exists.")
1870-
def package_plugin(path: str, output_path: str, force: bool) -> None:
1872+
@click.option("--automax-requires", default=">=0.1.0", show_default=True, help="Automax version range written to the package manifest.")
1873+
def package_plugin(path: str, output_path: str, force: bool, automax_requires: str) -> None:
18711874
"""Package checked external plugin sources."""
18721875
try:
1873-
output = package_external_plugin_path(path, output_path, force=force)
1876+
output = package_external_plugin_path(
1877+
path,
1878+
output_path,
1879+
force=force,
1880+
automax_requires=automax_requires,
1881+
)
18741882
except ValueError as exc:
18751883
raise click.ClickException(str(exc)) from exc
18761884
click.echo(f"Wrote {output}")
18771885

18781886

1887+
@plugins.command("verify-package")
1888+
@click.argument("package", type=click.Path(exists=True, dir_okay=False))
1889+
@click.option(
1890+
"--format",
1891+
"output_format",
1892+
type=click.Choice(["text", "json"]),
1893+
default="text",
1894+
show_default=True,
1895+
help="Output format.",
1896+
)
1897+
def verify_plugin_package(package: str, output_format: str) -> None:
1898+
"""Verify an external plugin package manifest and file checksums."""
1899+
try:
1900+
payload = verify_external_plugin_package(package)
1901+
except ValueError as exc:
1902+
raise click.ClickException(str(exc)) from exc
1903+
if output_format == "json":
1904+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
1905+
else:
1906+
click.echo(render_plugin_package_verify_text(payload))
1907+
if not payload["ok"]:
1908+
raise click.exceptions.Exit(1)
1909+
1910+
18791911
@plugins.command("describe")
18801912
@click.argument("name")
18811913
@click.option("--plugin-path", multiple=True, help="External plugin file or directory.")

src/automax/plugins/sdk.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import hashlib
89
import json
910
import re
1011
import zipfile
@@ -177,7 +178,37 @@ def render_plugin_check_text(payload: Dict[str, Any]) -> str:
177178
return "\n".join(lines)
178179

179180

180-
def package_external_plugin_path(path: str, output_path: str, *, force: bool = False) -> Path:
181+
182+
def _sha256_file(path: Path) -> str:
183+
digest = hashlib.sha256()
184+
with path.open("rb") as handle:
185+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
186+
digest.update(chunk)
187+
return digest.hexdigest()
188+
189+
190+
def _sha256_bytes(data: bytes) -> str:
191+
return hashlib.sha256(data).hexdigest()
192+
193+
194+
def _package_file_metadata(files: list[Path], root: Path) -> list[Dict[str, Any]]:
195+
return [
196+
{
197+
"path": item.relative_to(root).as_posix(),
198+
"size": item.stat().st_size,
199+
"sha256": _sha256_file(item),
200+
}
201+
for item in files
202+
]
203+
204+
205+
def package_external_plugin_path(
206+
path: str,
207+
output_path: str,
208+
*,
209+
force: bool = False,
210+
automax_requires: str = ">=0.1.0",
211+
) -> Path:
181212
"""Package external plugin sources and metadata into a ZIP archive."""
182213
source = Path(path).expanduser().resolve()
183214
output = Path(output_path).expanduser().resolve()
@@ -206,12 +237,127 @@ def package_external_plugin_path(path: str, output_path: str, *, force: bool = F
206237
output.parent.mkdir(parents=True, exist_ok=True)
207238
manifest = {
208239
"format": "automax-external-plugin-package-v1",
240+
"automax_requires": automax_requires,
209241
"source": str(source),
210242
"plugin_count": payload["checked"],
211243
"plugins": [plugin["name"] for plugin in payload["plugins"]],
244+
"files": _package_file_metadata(files, root),
212245
}
213246
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
214247
archive.writestr("automax-plugin.json", json.dumps(manifest, indent=2, sort_keys=True) + "\n")
215248
for item in files:
216249
archive.write(item, item.relative_to(root).as_posix())
217250
return output
251+
252+
253+
def verify_external_plugin_package(package_path: str) -> Dict[str, Any]:
254+
"""Verify one packaged external plugin ZIP manifest and file checksums."""
255+
package = Path(package_path).expanduser().resolve()
256+
failures: list[str] = []
257+
manifest: Dict[str, Any] = {}
258+
259+
if not package.exists():
260+
raise PluginRegistryError(f"plugin package not found: {package}")
261+
if not package.is_file():
262+
raise PluginRegistryError(f"plugin package is not a file: {package}")
263+
264+
try:
265+
with zipfile.ZipFile(package) as archive:
266+
names = archive.namelist()
267+
if "automax-plugin.json" not in names:
268+
failures.append("automax-plugin.json manifest is missing")
269+
else:
270+
try:
271+
manifest = json.loads(archive.read("automax-plugin.json").decode("utf-8"))
272+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
273+
failures.append(f"automax-plugin.json is invalid: {exc}")
274+
275+
if manifest:
276+
if manifest.get("format") != "automax-external-plugin-package-v1":
277+
failures.append("manifest format must be automax-external-plugin-package-v1")
278+
plugins = manifest.get("plugins") or []
279+
if not isinstance(plugins, list) or not all(isinstance(item, str) for item in plugins):
280+
failures.append("manifest plugins must be a list of plugin names")
281+
plugins = []
282+
for name in plugins:
283+
if not PLUGIN_NAME_RE.match(name):
284+
failures.append(f"{name}: manifest plugin name must be canonical")
285+
if manifest.get("plugin_count") != len(plugins):
286+
failures.append("manifest plugin_count does not match plugins")
287+
if not manifest.get("automax_requires"):
288+
failures.append("manifest automax_requires is required")
289+
290+
file_entries = manifest.get("files") or []
291+
if not isinstance(file_entries, list):
292+
failures.append("manifest files must be a list")
293+
file_entries = []
294+
seen: set[str] = set()
295+
for entry in file_entries:
296+
if not isinstance(entry, dict):
297+
failures.append("manifest file entries must be objects")
298+
continue
299+
rel = str(entry.get("path") or "")
300+
expected_hash = str(entry.get("sha256") or "")
301+
expected_size = entry.get("size")
302+
if not rel or rel.startswith("/") or ".." in Path(rel).parts:
303+
failures.append(f"invalid package file path: {rel or '<empty>'}")
304+
continue
305+
if rel in seen:
306+
failures.append(f"duplicate package file entry: {rel}")
307+
continue
308+
seen.add(rel)
309+
if rel not in names:
310+
failures.append(f"packaged file missing from archive: {rel}")
311+
continue
312+
data = archive.read(rel)
313+
if expected_hash != _sha256_bytes(data):
314+
failures.append(f"checksum mismatch: {rel}")
315+
if not isinstance(expected_size, int) or expected_size != len(data):
316+
failures.append(f"size mismatch: {rel}")
317+
318+
packaged_sources = sorted(
319+
name for name in names
320+
if name.endswith(".py") and not name.startswith("__pycache__/")
321+
)
322+
declared_sources = sorted(
323+
str(entry.get("path"))
324+
for entry in file_entries
325+
if isinstance(entry, dict) and str(entry.get("path") or "").endswith(".py")
326+
)
327+
if packaged_sources != declared_sources:
328+
failures.append("manifest files do not match packaged Python sources")
329+
except zipfile.BadZipFile as exc:
330+
failures.append(f"invalid ZIP package: {exc}")
331+
332+
return {
333+
"ok": not failures,
334+
"package": str(package),
335+
"format": manifest.get("format") if manifest else None,
336+
"automax_requires": manifest.get("automax_requires") if manifest else None,
337+
"plugin_count": manifest.get("plugin_count") if manifest else 0,
338+
"plugins": manifest.get("plugins", []) if manifest else [],
339+
"files": manifest.get("files", []) if manifest else [],
340+
"failure_count": len(failures),
341+
"failures": failures,
342+
}
343+
344+
345+
def render_plugin_package_verify_text(payload: Dict[str, Any]) -> str:
346+
"""Render package verification results as text."""
347+
lines = ["External plugin package verification:"]
348+
lines.append(f" package: {payload['package']}")
349+
lines.append(f" plugins: {payload['plugin_count']}")
350+
lines.append(f" files: {len(payload['files'])}")
351+
lines.append(f" failures: {payload['failure_count']}")
352+
if payload.get("automax_requires"):
353+
lines.append(f" requires: {payload['automax_requires']}")
354+
if payload["plugins"]:
355+
lines.append("Plugins:")
356+
for name in payload["plugins"]:
357+
lines.append(f" - {name}")
358+
if payload["failures"]:
359+
lines.append("Failures:")
360+
for failure in payload["failures"]:
361+
lines.append(f" - {failure}")
362+
lines.append(f"Result: {'OK' if payload['ok'] else 'FAILED'}")
363+
return "\n".join(lines)

tests/test_documentation_and_regressions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ def test_external_plugin_sdk_commands_are_documented():
474474
assert "automax plugins init" in docs
475475
assert "automax plugins check" in docs
476476
assert "automax plugins package" in docs
477+
assert "automax plugins verify-package" in docs
477478
assert "--plugin-path" in docs
478479

479480

tests/test_engine.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1970,7 +1970,46 @@ def test_plugins_init_check_package_external_sdk(tmp_path: Path):
19701970
assert "demo_echo.py" in archive.namelist()
19711971
manifest = json.loads(archive.read("automax-plugin.json").decode("utf-8"))
19721972
assert manifest["format"] == "automax-external-plugin-package-v1"
1973+
assert manifest["automax_requires"] == ">=0.1.0"
19731974
assert manifest["plugins"] == ["demo.echo"]
1975+
assert manifest["files"][0]["path"] == "demo_echo.py"
1976+
assert manifest["files"][0]["sha256"]
1977+
1978+
verify = runner.invoke(cli, ["plugins", "verify-package", str(package_path)])
1979+
assert verify.exit_code == 0, verify.output
1980+
assert "External plugin package verification:" in verify.output
1981+
assert "demo.echo" in verify.output
1982+
assert "Result: OK" in verify.output
1983+
1984+
json_verify = runner.invoke(cli, ["plugins", "verify-package", str(package_path), "--format", "json"])
1985+
assert json_verify.exit_code == 0, json_verify.output
1986+
verify_payload = json.loads(json_verify.output)
1987+
assert verify_payload["ok"] is True
1988+
assert verify_payload["automax_requires"] == ">=0.1.0"
1989+
1990+
1991+
def test_plugins_verify_package_rejects_checksum_mismatch(tmp_path: Path):
1992+
plugin_dir = tmp_path / "plugins"
1993+
package_path = tmp_path / "demo-plugin.zip"
1994+
bad_package_path = tmp_path / "bad-plugin.zip"
1995+
runner = CliRunner()
1996+
1997+
assert runner.invoke(cli, ["plugins", "init", "demo.echo", "--output", str(plugin_dir)]).exit_code == 0
1998+
package = runner.invoke(cli, ["plugins", "package", str(plugin_dir), "--output", str(package_path)])
1999+
assert package.exit_code == 0, package.output
2000+
2001+
with zipfile.ZipFile(package_path) as source:
2002+
manifest = json.loads(source.read("automax-plugin.json").decode("utf-8"))
2003+
plugin_source = source.read("demo_echo.py")
2004+
manifest["files"][0]["sha256"] = "0" * 64
2005+
with zipfile.ZipFile(bad_package_path, "w") as archive:
2006+
archive.writestr("automax-plugin.json", json.dumps(manifest))
2007+
archive.writestr("demo_echo.py", plugin_source)
2008+
2009+
result = runner.invoke(cli, ["plugins", "verify-package", str(bad_package_path)])
2010+
2011+
assert result.exit_code == 1, result.output
2012+
assert "checksum mismatch: demo_echo.py" in result.output
19742013

19752014

19762015
def test_plugins_check_rejects_incomplete_external_metadata(tmp_path: Path):

0 commit comments

Comments
 (0)