181 lines
6.1 KiB
Python
181 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Create one authenticated, no-side-effect Task Plane Shadow task.
|
|
|
|
The client deliberately has no worker, Git, deployment, or credential-writing
|
|
capabilities. Its token is accepted only from a protected file reference or
|
|
stdin so that it cannot enter the process command line.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
MINIMUM_CLIENT_CAPABILITIES = (
|
|
"task.creator",
|
|
"task.read",
|
|
"task.events.read",
|
|
"task.evidence.read",
|
|
)
|
|
|
|
|
|
class ShadowClientError(RuntimeError):
|
|
"""HTTP failure that intentionally carries no response body."""
|
|
|
|
def __init__(self, status: int):
|
|
super().__init__(f"shadow task request failed with HTTP {status}")
|
|
self.status = status
|
|
|
|
|
|
def _request_json(
|
|
base_url: str,
|
|
path: str,
|
|
token: str,
|
|
*,
|
|
method: str = "GET",
|
|
payload: dict[str, Any] | None = None,
|
|
idempotency_key: str | None = None,
|
|
) -> tuple[int, dict[str, Any]]:
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"Authorization": f"Bearer {token}",
|
|
}
|
|
data = None
|
|
if payload is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
if idempotency_key:
|
|
headers["Idempotency-Key"] = idempotency_key
|
|
|
|
request = urllib.request.Request(
|
|
base_url.rstrip("/") + path,
|
|
data=data,
|
|
headers=headers,
|
|
method=method,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=10) as response:
|
|
body = response.read()
|
|
parsed = json.loads(body.decode("utf-8")) if body else {}
|
|
return response.status, parsed
|
|
except urllib.error.HTTPError as error:
|
|
# Never read or print an error body: it could contain sensitive data.
|
|
raise ShadowClientError(error.code) from None
|
|
except urllib.error.URLError as error:
|
|
raise ShadowClientError(503) from error
|
|
|
|
|
|
def run_shadow_client(
|
|
*,
|
|
base_url: str,
|
|
token: str,
|
|
task_id: str,
|
|
idempotency_key: str,
|
|
) -> dict[str, Any]:
|
|
"""Create and read one deterministic Shadow task without executing it."""
|
|
create_status, _ = _request_json(
|
|
base_url,
|
|
"/tasks",
|
|
token,
|
|
method="POST",
|
|
idempotency_key=idempotency_key,
|
|
payload={
|
|
"task_id": task_id,
|
|
"task_type": "platform.shadow",
|
|
"risk": "low",
|
|
"approval_policy": "patch-only",
|
|
"metadata": {"production_side_effects": False},
|
|
},
|
|
)
|
|
task_status, task = _request_json(base_url, f"/tasks/{task_id}", token)
|
|
events_status, events = _request_json(base_url, f"/tasks/{task_id}/events", token)
|
|
evidence_status, evidence = _request_json(base_url, f"/tasks/{task_id}/evidence", token)
|
|
return {
|
|
"task_id": str(task.get("task_id", task_id)),
|
|
"status": str(task.get("status", "UNKNOWN")),
|
|
"event_count": len(events if isinstance(events, list) else events.get("events", [])),
|
|
"evidence_count": len(evidence if isinstance(evidence, list) else evidence.get("evidence", [])),
|
|
"http_statuses": {
|
|
"create": create_status,
|
|
"task": task_status,
|
|
"events": events_status,
|
|
"evidence": evidence_status,
|
|
},
|
|
"production_side_effects": False,
|
|
}
|
|
|
|
|
|
def _reject_plaintext_token_argument(argv: list[str]) -> None:
|
|
if any(argument == "--token" or argument.startswith("--token=") for argument in argv):
|
|
raise SystemExit("plaintext token arguments are forbidden")
|
|
|
|
|
|
def _read_token(args: argparse.Namespace) -> str:
|
|
if args.token_stdin:
|
|
token = sys.stdin.buffer.read().decode("utf-8").strip()
|
|
else:
|
|
token_file = args.token_file or os.environ.get("AIOS_TASK_PLANE_TOKEN_FILE")
|
|
if not token_file:
|
|
raise SystemExit("runtime credential reference is required")
|
|
try:
|
|
token = Path(token_file).read_text(encoding="utf-8").strip()
|
|
except OSError:
|
|
raise SystemExit("runtime credential reference is unavailable") from None
|
|
if not token:
|
|
raise SystemExit("runtime credential reference is unavailable")
|
|
return token
|
|
|
|
|
|
def _write_report(path: Path, summary: dict[str, Any]) -> str:
|
|
encoded = (json.dumps(summary, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(encoded)
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
_reject_plaintext_token_argument(argv)
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--base-url", default="http://127.0.0.1:18922")
|
|
token_group = parser.add_mutually_exclusive_group()
|
|
token_group.add_argument("--token-file", help="protected runtime credential reference")
|
|
token_group.add_argument("--token-stdin", action="store_true")
|
|
parser.add_argument("--task-id", default="AIOS-TASK-PLANE-SHADOW-CLIENT")
|
|
parser.add_argument("--idempotency-key", default="task-plane-shadow-client-v1")
|
|
parser.add_argument("--report", required=True, type=Path)
|
|
args = parser.parse_args(argv)
|
|
|
|
token = _read_token(args)
|
|
try:
|
|
summary = run_shadow_client(
|
|
base_url=args.base_url,
|
|
token=token,
|
|
task_id=args.task_id,
|
|
idempotency_key=args.idempotency_key,
|
|
)
|
|
except ShadowClientError as error:
|
|
print(f"http_status={error.status}", file=sys.stderr)
|
|
return error.status
|
|
|
|
report_sha256 = _write_report(args.report, summary)
|
|
print(f"task_id={summary['task_id']}")
|
|
print(f"status={summary['status']}")
|
|
print(f"event_count={summary['event_count']}")
|
|
print(f"evidence_count={summary['evidence_count']}")
|
|
print("http_statuses=" + json.dumps(summary["http_statuses"], sort_keys=True))
|
|
print(f"report_sha256={report_sha256}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|