"""Task Plane HTTP API implementation (stdlib-only).""" from __future__ import annotations import json import os import time import uuid import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Callable from .delegation import DelegationEngine from .errors import ( ConflictError, ForbiddenError, NotFoundError, PayloadTooLargeError, TaskPlaneHTTPError, UnauthorizedError, ValidationError, ) from .evidence import EvidenceService from .journal import TaskJournal from .lease import LeaseManager from .models import ( EvidenceEnvelope, ResultEnvelope, TaskPlan, TaskRecord, iso8601_now, contains_secret_text, validate_identifier, ) from .policy import PolicyEngine from .store import TaskPlaneStore from .scope import bind_task_scope from .worker_registry import WorkerRegistry def _json_payload(data: dict[str, Any], status: int = 200) -> tuple[int, bytes]: body = json.dumps(data, ensure_ascii=False).encode("utf-8") return status, body def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int: try: number = int(value) except (TypeError, ValueError) as exc: raise ValidationError(f"invalid {name}") from exc if not minimum <= number <= maximum: raise ValidationError(f"invalid {name}") return number class TaskPlaneService: def __init__(self, store: TaskPlaneStore, policy: PolicyEngine | None = None): self.store = store self.policy = policy or PolicyEngine( execution_enabled=False, identity_capabilities={ "anon": {"task.creator", "task.read", "task.audit"} }, ) self.journal = TaskJournal(store) self.lease = LeaseManager(store) self.workers = WorkerRegistry(store) self.evidence = EvidenceService(store) self.delegation = DelegationEngine(store) self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier def _authorized_task(self, task_id: str, actor: str, action: str) -> TaskRecord: task = self.store.get_task(task_id, self.policy.actor_context(actor)) self.policy.assert_allowed(action, actor, task=task) return task def create_task( self, body: dict[str, Any], idempotency_key: str | None = None, *, actor_identity: str | None = None, ) -> dict[str, Any]: if body.get("parent_task_id"): raise ForbiddenError("child tasks must use the delegate endpoint") issuer = body.get("issuer", {}) declared_actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon" actor = actor_identity or declared_actor self.policy.assert_allowed("create", actor) try: plan = TaskPlan.from_dict(body) except (TypeError, ValueError) as exc: raise ValidationError("invalid task plan") from exc if actor_identity is not None: plan = bind_task_scope( plan, self.policy.actor_context(actor_identity), public_create=True ) task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key) return task.to_dict() def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]: context = None if actor is not None: self.policy.assert_allowed("read", actor) context = self.policy.actor_context(actor) return [ task.to_dict(include_task=False) for task in self.store.list_tasks(context) ] def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]: context = None if actor is not None: self.policy.assert_allowed("read", actor) context = self.policy.actor_context(actor) task = self.store.get_task(task_id, context) if actor is not None: self.policy.assert_allowed("read", actor, task=task) return task.to_dict() def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]: if actor is not None: self.policy.assert_allowed("read", actor) return self.workers.get(worker_id).to_dict() def list_workers(self, actor: str | None = None) -> list[dict[str, Any]]: if actor is not None: self.policy.assert_allowed("read", actor) return [w.to_dict() for w in self.workers.list()] def register_worker(self, body: dict[str, Any]) -> dict[str, Any]: actor = str(body.get("actor_identity") or body.get("identity") or "") is_admin = actor in self.policy.admin_identities try: existing = self.store.get_worker(str(body.get("worker_id") or "")) except NotFoundError: existing = None if existing is not None and str(body.get("identity") or "") != existing.identity: raise ConflictError("worker identity takeover rejected") self.policy.assert_readonly_worker_registration(actor, body) self.policy.assert_allowed( "worker.admin" if is_admin else "worker.register", actor ) try: worker = self.workers.register( body, actor_identity=actor, is_admin=is_admin, expected_revision=body.get("expected_revision"), ) except (TypeError, ValueError, KeyError) as exc: raise ValidationError("invalid worker registration") from exc return worker.to_dict() def worker_heartbeat(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]: interval = payload.get("heartbeat_interval_seconds") actor = str(payload.get("actor_identity") or "") self.policy.assert_readonly_worker_binding(actor, worker_id) self.policy.assert_allowed("worker.register", actor) return self.workers.heartbeat( worker_id, heartbeat_interval_seconds=interval, actor_identity=actor, expected_revision=payload.get("expected_revision"), ).to_dict() def disable_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]: actor = str(payload.get("actor_identity") or "") self.policy.assert_allowed("worker.admin", actor) return self.store.disable_worker( worker_id, actor_identity=actor, expected_revision=int(payload.get("expected_revision")), reason=str(payload.get("reason") or "disabled by administrator"), ).to_dict() def restore_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]: actor = str(payload.get("actor_identity") or "") self.policy.assert_allowed("worker.admin", actor) return self.store.restore_worker( worker_id, actor_identity=actor, expected_revision=int(payload.get("expected_revision")), ).to_dict() def claim(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]: actor = body.get("actor_identity") or body.get("worker_identity") or "worker" self.policy.assert_allowed("claim", actor) worker_id = body.get("worker_id") if not worker_id: raise ValidationError("worker_id required") task = self._authorized_task(task_id, actor, "claim") self.policy.assert_readonly_worker_binding(actor, str(worker_id)) lease_seconds = _bounded_int( body.get("lease_seconds", 30), "lease_seconds", 1, 3600 ) claim = self.lease.claim( task_id=task_id, worker_id=worker_id, worker_identity=actor, lease_seconds=lease_seconds, expected_revision=task.revision, operation_digest=task.task_plan.operation_digest(), ) return {"claim_id": claim.claim_id, **claim.to_dict()} def heartbeat(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]: claim_id = body.get("claim_id") if not claim_id: raise ValidationError("claim_id required") actor = body.get("actor_identity") or body.get("worker_identity") or "worker" task = self._authorized_task(task_id, actor, "heartbeat") claim = self.store.get_claim(claim_id) self.policy.assert_readonly_worker_binding(actor, claim.worker_id) if claim.worker_identity != actor: raise ForbiddenError("claim identity mismatch") lease_seconds = _bounded_int( body.get("lease_seconds", 30), "lease_seconds", 1, 3600 ) claim = self.lease.heartbeat(task_id, claim_id, lease_seconds) return {"claim_id": claim_id, "lease_until": claim.lease_until} def progress(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]: claim_id = body.get("claim_id") if not claim_id: raise ValidationError("claim_id required") actor = body.get("actor_identity") or body.get("worker_identity") or "worker" progress = body.get("progress") stage = body.get("stage", "RUNNING") message = body.get("message") if progress is None: raise ValidationError("progress required") task = self._authorized_task(task_id, actor, "progress") claim = self.store.get_claim(claim_id) self.policy.assert_readonly_worker_binding(actor, claim.worker_id) if task.task_plan.data_classification == "credential-secret": if message not in {None, ""} or contains_secret_text(stage): raise ValidationError( "credential-secret progress must contain metadata only" ) lease_seconds = _bounded_int( body.get("lease_seconds", 30), "lease_seconds", 1, 3600 ) claim = self.store.record_progress( task_id, claim_id, actor, progress=progress, stage=str(stage), message=None if message is None else str(message), lease_seconds=lease_seconds, ) return {"claim_id": claim.claim_id, "status": claim.status, "progress": progress} def complete(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]: claim_id = body.get("claim_id") if not claim_id: raise ValidationError("claim_id required") actor = body.get("actor_identity") or body.get("worker_identity") or "worker" task = self._authorized_task(task_id, actor, "result") status = body.get("status", "COMPLETED") outputs = body.get("outputs", []) evidences = body.get("evidence", []) if not isinstance(outputs, list): raise ValidationError("outputs must be list") if not isinstance(evidences, list): raise ValidationError("evidence must be list") if any(not isinstance(ev, dict) for ev in evidences): raise ValidationError("evidence items must be objects") claim = self.store.get_claim(claim_id) self.policy.assert_readonly_worker_binding(actor, claim.worker_id) if claim.worker_identity != actor: raise ForbiddenError("claim identity mismatch") if task.task_plan.data_classification == "credential-secret": allowed_result_keys = { "kind", "hash", "sha256", "count", "status", "error_code", "signature", "attestation_ref", } for entry in [*outputs, *evidences]: if ( not isinstance(entry, dict) or set(entry) - allowed_result_keys or contains_secret_text(entry) ): raise ValidationError( "credential-secret result must contain hash/count/status only" ) result = ResultEnvelope( task_id=task_id, claim_id=claim_id, worker_identity=actor, status=status, outputs=outputs, evidence=evidences, started_at=str(body.get("started_at") or iso8601_now()), completed_at=str(body.get("completed_at") or iso8601_now()), result_digest=body.get("result_digest"), signature=body.get("signature"), attestation_ref=body.get("attestation_ref"), operation_digest=claim.operation_digest, ) evidence_decision = self.policy.evidence_verifier.verify(task, evidences) if not evidence_decision.allowed: raise ForbiddenError(evidence_decision.reason) result_decision = self.policy.verify_result(result, task) if not result_decision.allowed: raise ForbiddenError(result_decision.reason) result.result_digest = result.computed_digest() previous = self.store._last_evidence_hash(task_id) evidence_entries = [] for evidence in evidences: envelope = EvidenceEnvelope.create( task_id=task_id, claim_id=claim_id, kind=str(evidence.get("kind") or evidence.get("type") or "generic"), worker_identity=actor, payload=evidence, previous_evidence_hash=previous, signature=evidence.get("signature"), attestation_ref=evidence.get("attestation_ref"), ) evidence_entries.append(envelope) previous = envelope.evidence_digest self.store.complete_claim_with_evidence_and_event( task_id, claim_id, status if status in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"} else "COMPLETED", result.to_dict(), evidence_entries, ) return result.to_dict() def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]: claim_id = body.get("claim_id") if not claim_id: raise ValidationError("claim_id required") reason = body.get("reason", "failed") actor = body.get("actor_identity") or body.get("worker_identity") or "worker" self.policy.assert_allowed("fail", actor) task = self._authorized_task(task_id, actor, "fail") claim = self.store.get_claim(claim_id) if claim.worker_identity != actor: raise ForbiddenError("claim identity mismatch") status = "FAILED_RECOVERABLE" if body.get("recoverable", False) else "FAILED_FINAL" self.store.fail_claim_with_event(task_id, claim_id, status, str(reason)) return {"task_id": task_id, "status": status, "reason": reason} def cancel(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]: actor = body.get("actor_identity") or body.get("worker_identity") or "owner" self.policy.assert_allowed("cancel", actor) reason = body.get("reason", "cancelled") task = self._authorized_task(task_id, actor, "cancel") updated = self.store.cancel_task_with_event( task_id, task.revision, reason=reason ) return updated.to_dict() def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]: actor = body.get("actor_identity") or "owner" self.policy.assert_allowed("delegate", actor) parent = self._authorized_task(task_id, actor, "delegate") if "task" in body: try: child_plan = TaskPlan.from_dict(body["task"]) except (TypeError, ValueError) as exc: raise ValidationError("invalid child task plan") from exc else: plan_body = dict(body) plan_body.pop("actor_identity", None) try: child_plan = TaskPlan.from_dict(plan_body) except (TypeError, ValueError) as exc: raise ValidationError("invalid child task plan") from exc child_plan.parent_task_id = task_id child_plan = bind_task_scope( child_plan, self.policy.actor_context(actor), public_create=False ) child = self.delegation.delegate(task_id, child_plan, caller=actor) return child.to_dict() def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]: if actor is not None: self._authorized_task(task_id, actor, "audit") else: self.store.get_task(task_id) return self.store.list_events(task_id) def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]: if actor is not None: self._authorized_task(task_id, actor, "audit") else: self.store.get_task(task_id) return self.evidence.list(task_id) def get_children(self, task_id: str, actor: str | None = None) -> list[str]: if actor is not None: self._authorized_task(task_id, actor, "read") else: self.store.get_task(task_id) return self.store.list_children(task_id) class TaskPlaneRequestHandler(BaseHTTPRequestHandler): service: TaskPlaneService | None = None authenticator: Callable[[str], str | None] | None = None max_body_bytes = 1_000_000 def log_message(self, format: str, *args: Any) -> None: # BaseHTTPRequestHandler logs the raw request target; suppress it so # attacker-controlled paths or query strings cannot leak credentials. return def _json(self) -> tuple[str | None, dict[str, Any]]: try: length = int(self.headers.get("Content-Length", "0")) except ValueError as exc: raise ValidationError("invalid content length") from exc if length < 0: raise ValidationError("invalid content length") if length > self.max_body_bytes: raise PayloadTooLargeError("payload exceeds limit") raw = self.rfile.read(length) if length > 0 else b"" if not raw: return None, {} try: decoded = raw.decode("utf-8") payload = json.loads(decoded) if not isinstance(payload, dict): raise ValidationError("JSON payload must be an object") return decoded, payload except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValidationError("invalid JSON payload") from exc def _response(self, status: int, payload: Any) -> None: encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(encoded))) self.end_headers() self.wfile.write(encoded) def _authenticated_identity(self) -> str: authenticator = type(self).authenticator if authenticator is None: raise UnauthorizedError("authentication unavailable") header = self.headers.get("Authorization", "") if not header.startswith("Bearer "): raise UnauthorizedError("bearer authentication required") token = header[7:] if not token or any(ord(ch) < 33 for ch in token): raise UnauthorizedError("invalid bearer token") identity = authenticator(token) if not identity: raise UnauthorizedError("invalid credentials") return str(identity) def _handle(self, handler): try: if self.service is None: raise TaskPlaneHTTPError("service not configured") result = handler() if isinstance(result, tuple): status, payload = result else: status, payload = 200, result self._response(status, payload) except TaskPlaneHTTPError as exc: self._response(exc.status_code, exc.to_payload()) except Exception as exc: # defensive if isinstance(exc, TaskPlaneHTTPError): raise self._response( 500, {"error": "internal_error", "message": "internal server error"}, ) def do_GET(self): # noqa: N802 def route(): path = urllib.parse.urlparse(self.path).path parts = [p for p in path.split("/") if p] for part in parts[1:]: if part not in {"events", "evidence", "children", "heartbeat"}: try: validate_identifier(part, "resource identifier") except ValueError as exc: raise ValidationError("invalid resource identifier") from exc identity = self._authenticated_identity() if len(parts) == 1 and parts[0] == "tasks": return 200, self.service.list_tasks(identity) if len(parts) == 2 and parts[0] == "tasks": task_id = parts[1] return 200, self.service.get_task(task_id, identity) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "events": return 200, self.service.get_events(parts[1], identity) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "evidence": return 200, self.service.get_evidence(parts[1], identity) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "children": return 200, self.service.get_children(parts[1], identity) if len(parts) == 1 and parts[0] == "workers": return 200, self.service.list_workers(identity) if len(parts) == 2 and parts[0] == "workers": return 200, self.service.get_worker(parts[1], identity) raise NotFoundError("unknown endpoint") self._handle(route) def do_POST(self): # noqa: N802 def route(): body_text, body = self._json() identity = self._authenticated_identity() path = urllib.parse.urlparse(self.path).path parts = [p for p in path.split("/") if p] headers = {k.lower(): v for k, v in self.headers.items()} if len(parts) == 1 and parts[0] == "tasks": issuer = body.get("issuer") if issuer and ( not isinstance(issuer, dict) or issuer.get("id") != identity ): raise ForbiddenError("issuer identity mismatch") body["issuer"] = {"type": "authenticated", "id": identity} return 201, self.service.create_task( body or {}, headers.get("idempotency-key"), actor_identity=identity, ) if body.get("actor_identity") not in {None, identity}: raise ForbiddenError("actor identity mismatch") body["actor_identity"] = identity if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "claim": return 200, self.service.claim(parts[1], body or {}) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "heartbeat": return 200, self.service.heartbeat(parts[1], body or {}) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "progress": return 200, self.service.progress(parts[1], body or {}) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "result": return 200, self.service.complete(parts[1], body or {}) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "fail": return 200, self.service.fail(parts[1], body or {}) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel": return 200, self.service.cancel(parts[1], body or {}) if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate": return 200, self.service.delegate(parts[1], body or {}) if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register": if ( body.get("identity") != identity and identity not in self.service.policy.admin_identities ): raise ForbiddenError("worker identity mismatch") return 201, self.service.register_worker(body or {}) if len(parts) == 3 and parts[0] == "workers" and parts[2] == "heartbeat": return 200, self.service.worker_heartbeat(parts[1], body or {}) if len(parts) == 3 and parts[0] == "workers" and parts[2] == "disable": return 200, self.service.disable_worker(parts[1], body or {}) if len(parts) == 3 and parts[0] == "workers" and parts[2] == "restore": return 200, self.service.restore_worker(parts[1], body or {}) raise NotFoundError("unknown endpoint") self._handle(route) def run_server( host: str, port: int, service: TaskPlaneService, *, authenticator: Callable[[str], str | None] | None = None, ): handler = TaskPlaneRequestHandler handler.service = service handler.authenticator = authenticator server = ThreadingHTTPServer((host, port), handler) server.serve_forever()