stack: MCP submit-Tool mit Upload Review Gate und Quarantäne-Schreibpfad (schliesst #32)
Files changed: - .gitignore - AGENTS.md - CHANGES.md - INSTALL-MCP.md - README.md - VERSION - docs/why-gates-are-code.md - instructions/gates.md - instructions/ingest-queue.md - instructions/mcp-read-server.md - instructions/wiki-ingest/SKILL.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/chemenu/cli.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/upload_cmd.py - tools/chemenu/config.py - tools/chemenu/mcp/server.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_mcp_server.py - tools/chemenu/tests/test_upload.py - tools/chemenu/tests/test_upload_cmd.py - tools/chemenu/upload.py
This commit is contained in:
1 parent
4781140375
commit
828521861d
24 files changed
+1866
-56
No files matched your search
@@ -120,6 +120,14 @@ REQUIRED_IGNORE_CANARIES = (
|
||||
# backstop a few lines above. Flat since Gitea #67 - incoming/ no longer
|
||||
# has type subdirectories, so the probe sits directly in it.
|
||||
"incoming/probe.pdf",
|
||||
# The MCP `submit` tool's quarantine (Gitea #32) - stronger than
|
||||
# `incoming/` above: read by no command in the ordinary pipeline, not
|
||||
# only uncommitted. No type subdirectory either, for the same reason.
|
||||
"mcp-upload/probe.pdf",
|
||||
# The `submit` tool's opt-in, same shape as `.wikitool-remotes.json`/
|
||||
# `.wikitool-telemetry.json` a few lines below - per-checkout, never
|
||||
# committed.
|
||||
".wikitool-upload.json",
|
||||
)
|
||||
REQUIRED_TRACKED_PATHS = (
|
||||
"reports/CONTRACT.md",
|
||||
|
||||
@@ -376,6 +376,43 @@ def check_telemetry() -> Check:
|
||||
)
|
||||
|
||||
|
||||
def check_upload_intake() -> Check:
|
||||
"""Whether the MCP `submit` tool is armed for this checkout, and how full
|
||||
its quarantine is.
|
||||
|
||||
Absent is the *safe* default here, unlike `check_publish_remotes`'s "any
|
||||
push target passes" absence: no `.wikitool-upload.json` means the write
|
||||
path does not exist at all, not that it is unrestricted - so this never
|
||||
`FAIL`s on a missing file. It does `FAIL` on one that parses to something
|
||||
invalid, because a broken opt-in must not silently disable the very
|
||||
limits it exists to enforce.
|
||||
"""
|
||||
from chemenu import upload as upload_module
|
||||
from chemenu.errors import ValidationError
|
||||
|
||||
try:
|
||||
cfg = upload_module.read_config(config.ROOT)
|
||||
except ValidationError as exc:
|
||||
return Check(
|
||||
"upload-intake", "FAIL", str(exc),
|
||||
f"Fix or delete {config.UPLOAD_CONFIG_FILENAME} - a broken one is not treated as "
|
||||
"'no limits'",
|
||||
)
|
||||
if cfg is None:
|
||||
return Check(
|
||||
"upload-intake", "OK",
|
||||
f"submit tool not registered - no {config.UPLOAD_CONFIG_FILENAME}",
|
||||
)
|
||||
pending = upload_module.list_submissions(config.ROOT)
|
||||
return Check(
|
||||
"upload-intake", "OK",
|
||||
f"submit tool armed (identity header {cfg.identity_header!r}, up to "
|
||||
f"{cfg.max_bytes:,} byte(s), {cfg.submissions_per_day}/day and "
|
||||
f"{cfg.bytes_per_day:,} byte(s)/day per submitter); "
|
||||
f"{len(pending)} submission(s) waiting in {rel_path(config.UPLOAD_DIR)}",
|
||||
)
|
||||
|
||||
|
||||
def check_session_id() -> Check:
|
||||
import os
|
||||
|
||||
@@ -478,6 +515,7 @@ def run_doctor() -> list[Check]:
|
||||
check_conventions(),
|
||||
check_environment(),
|
||||
check_publish_remotes(),
|
||||
check_upload_intake(),
|
||||
check_generated_files(),
|
||||
check_session_id(),
|
||||
check_telemetry(),
|
||||
@@ -490,8 +528,8 @@ def doctor_command(
|
||||
):
|
||||
"""Check that this instance is correctly configured: dependencies, author,
|
||||
git identity/remote, published skills, structure, personalization, KB
|
||||
conventions, generated files, session scoping, and telemetry state.
|
||||
Read-only. Exits 1 only
|
||||
conventions, generated files, session scoping, telemetry state, and
|
||||
whether the MCP `submit` tool is armed. Read-only. Exits 1 only
|
||||
if a check FAILs."""
|
||||
checks = run_doctor()
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""`wikitool upload list|show|accept|reject` - the human review side of the
|
||||
MCP submission quarantine (Gitea #32).
|
||||
|
||||
Everything that decides *whether* a submission was accepted or written at all
|
||||
lives in `chemenu.upload` - importable from the MCP server, stdlib only. What
|
||||
lives here instead is CLI-only by construction: the **Upload Review Gate**
|
||||
(`accept` refuses with Exit 42 until a human has seen the submission and
|
||||
re-runs with the printed `--confirm` token), and the two read commands a
|
||||
reviewer uses to look before clearing it. None of the four is importable
|
||||
from `chemenu.mcp.server` - they sit under `chemenu.commands`, the same
|
||||
boundary every other write command is already kept out by (AGENTS.md
|
||||
invariant 6, `instructions/gates.md`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as json_module
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, upload
|
||||
from chemenu.commands._util import fail, needs_clearance, rel_path, success
|
||||
from chemenu.errors import ValidationError
|
||||
|
||||
app = typer.Typer(help="Review, promote or reject MCP submissions waiting in mcp-upload/.")
|
||||
|
||||
|
||||
def _call(fn, *args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except ValidationError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def upload_list_command(
|
||||
json_out: bool = typer.Option(False, "--json", help="Print every waiting submission as JSON"),
|
||||
):
|
||||
"""List every submission currently waiting in mcp-upload/, oldest first."""
|
||||
manifests = _call(upload.list_submissions, config.ROOT)
|
||||
if json_out:
|
||||
typer.echo(json_module.dumps(manifests, indent=2))
|
||||
return
|
||||
if not manifests:
|
||||
typer.echo("Nothing is waiting in mcp-upload/.")
|
||||
return
|
||||
for manifest in manifests:
|
||||
typer.echo(
|
||||
f"{manifest['id']} {manifest['filename']} {manifest['size']}B "
|
||||
f"from {manifest['submitter']}"
|
||||
)
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def upload_show_command(
|
||||
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
|
||||
json_out: bool = typer.Option(False, "--json"),
|
||||
):
|
||||
"""Print one submission's manifest in full - what a reviewer checks
|
||||
before `accept`."""
|
||||
manifest = _call(upload.read_manifest, config.ROOT, submission_id)
|
||||
if json_out:
|
||||
typer.echo(json_module.dumps(manifest, indent=2))
|
||||
return
|
||||
for key in ("id", "filename", "size", "sha256", "submitter", "submitter_source", "submitted_at"):
|
||||
typer.echo(f"{key}: {manifest.get(key)}")
|
||||
|
||||
|
||||
def _clearance_message(manifest: dict, token: str, stale: Optional[str]) -> str:
|
||||
lines = [
|
||||
f"Upload Review Gate: submission '{manifest['id']}' needs a human to look at it "
|
||||
"before it is promoted into incoming/.",
|
||||
"",
|
||||
f" filename: {manifest['filename']}",
|
||||
f" size: {manifest['size']} bytes",
|
||||
f" sha256: {manifest['sha256']}",
|
||||
f" submitter: {manifest['submitter']}",
|
||||
f" submitter_source: {manifest['submitter_source']}",
|
||||
f" submitted_at: {manifest['submitted_at']}",
|
||||
"",
|
||||
]
|
||||
if stale:
|
||||
lines.append(
|
||||
f"The token you passed ({stale}) does not match this submission - its manifest "
|
||||
"changed, or the token was invented."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Nothing was promoted. Check this against raw/CONTRACT.md \"What does not belong "
|
||||
"here\" and instructions/ingest-queue.md, then re-run with the token below:"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f" tools/wikitool upload accept {manifest['id']} --confirm {token}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@app.command("accept")
|
||||
def upload_accept_command(
|
||||
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
|
||||
confirm: Optional[str] = typer.Option(
|
||||
None, "--confirm", help="The token from a prior refusal, once a human has reviewed it"
|
||||
),
|
||||
):
|
||||
"""Promote a submission into incoming/ - refuses with Exit 42 until a
|
||||
human has seen the manifest and cleared it with `--confirm <token>`."""
|
||||
manifest = _call(upload.read_manifest, config.ROOT, submission_id)
|
||||
token = upload.confirm_token(manifest)
|
||||
if confirm != token:
|
||||
needs_clearance(_clearance_message(manifest, token, confirm))
|
||||
return
|
||||
dest = _call(upload.promote, config.ROOT, submission_id)
|
||||
success(f"Promoted '{submission_id}' to {rel_path(dest)}.")
|
||||
|
||||
|
||||
@app.command("reject")
|
||||
def upload_reject_command(
|
||||
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
|
||||
reason: str = typer.Option(..., "--reason", help="Why this submission was declined"),
|
||||
):
|
||||
"""Delete a submission's material, keeping its ledger trail."""
|
||||
_call(upload.reject, config.ROOT, submission_id, reason)
|
||||
success(f"Rejected '{submission_id}': {reason}")
|
||||
Reference in new issue
Block a user