317 lines
10 KiB
Python
317 lines
10 KiB
Python
"""Task-scoped hardened wrapper around the existing Action API."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
import urllib.parse
|
|
|
|
import aios_chatgpt_actions_api as api
|
|
import aios_isolated_writer_policy as policy
|
|
from isolated_writer_authz import AuthorizationError, authorize
|
|
from isolated_writer_commit_file import AtomicCommitFile, CommitFileError, OperationLedger
|
|
from isolated_writer_token_registry import TokenRegistry
|
|
|
|
_reject = api.reject_non_read_http
|
|
_write_file = api.write_file
|
|
_sync_branch = api.sync_branch
|
|
_original_do_get = api.H.do_GET
|
|
api.OPENAPI = Path(os.getenv("AIOS_GITOPS_ISOLATED_WRITER_OPENAPI", str(api.OPENAPI)))
|
|
|
|
|
|
def _registry() -> TokenRegistry:
|
|
return TokenRegistry(Path(os.environ["AIOS_GITOPS_TOKEN_REGISTRY_V2"]))
|
|
|
|
|
|
def _atomic_writer() -> AtomicCommitFile:
|
|
return AtomicCommitFile(
|
|
workspace=api.WS,
|
|
bare_repo=api.BARE_REPO,
|
|
ledger=OperationLedger(Path(os.environ["AIOS_GITOPS_WRITER_OPERATION_LEDGER"])),
|
|
remote=os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", ""),
|
|
audit=policy.append_audit_record,
|
|
)
|
|
|
|
|
|
def _bearer(handler) -> str:
|
|
value = handler.headers.get("Authorization", "")
|
|
return value[7:].strip() if value.startswith("Bearer ") else ""
|
|
|
|
|
|
def _authorize(handler, *, method: str, path: str, branch=None, target_path=None):
|
|
grant = authorize(
|
|
_registry(),
|
|
_bearer(handler),
|
|
method=method,
|
|
path=path,
|
|
source_ip=str(handler.client_address[0]),
|
|
workspace=api.WS,
|
|
branch=branch,
|
|
target_path=target_path,
|
|
)
|
|
grant.record["_source_ip"] = str(handler.client_address[0])
|
|
return grant
|
|
|
|
|
|
def _authorization_error(handler, exc: AuthorizationError):
|
|
return handler.sendj({"error": exc.code}, exc.status)
|
|
|
|
|
|
def _isolated_runtime_status(handler):
|
|
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
|
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
|
return handler.sendj({
|
|
"service": os.getenv(
|
|
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
|
"aios-isolated-writer.service",
|
|
),
|
|
"status": "active",
|
|
"runtime_role": os.getenv("AIOS_GITOPS_RUNTIME_ROLE"),
|
|
"workspace_role": os.getenv("AIOS_GITOPS_WORKSPACE_ROLE"),
|
|
"writes_enabled": os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").lower()
|
|
in {"1", "true", "yes", "on"},
|
|
"allowed_branch": policy.allowed_branch(),
|
|
"approved_base_oid": policy.allowed_base_oid(),
|
|
"workspace_branch": branch,
|
|
"workspace_head_oid": head,
|
|
"repository_identity": os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY"),
|
|
"token_policy_version": 2,
|
|
"atomic_commit_file": True,
|
|
"path_policy_sha256": policy.configured_path_policy_sha256(),
|
|
})
|
|
|
|
|
|
def _isolated_do_get(self):
|
|
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
if path in {"/health", "/api/health"}:
|
|
return self.sendj({
|
|
"status": "ok",
|
|
"service": os.getenv(
|
|
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
|
"aios-isolated-writer.service",
|
|
),
|
|
})
|
|
if path not in {"/api/runtime-status", "/api/baseline", "/api/patches"}:
|
|
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
|
try:
|
|
_authorize(self, method="GET", path=path)
|
|
except AuthorizationError as exc:
|
|
return _authorization_error(self, exc)
|
|
if path == "/api/runtime-status":
|
|
return _isolated_runtime_status(self)
|
|
return _original_do_get(self)
|
|
|
|
|
|
def _read_commit_file_body(handler):
|
|
try:
|
|
length = int(handler.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
raise CommitFileError("writer_content_length_rejected") from None
|
|
if length <= 0:
|
|
raise CommitFileError("writer_request_object_required")
|
|
if length > 1_100_000:
|
|
raise CommitFileError("writer_request_too_large", 413)
|
|
raw = handler.rfile.read(length)
|
|
try:
|
|
value = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
raise CommitFileError("writer_request_json_rejected") from None
|
|
if not isinstance(value, dict):
|
|
raise CommitFileError("writer_request_object_required")
|
|
return value
|
|
|
|
|
|
def _isolated_do_post(self):
|
|
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
if path != "/api/commit-file":
|
|
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
|
try:
|
|
data = _read_commit_file_body(self)
|
|
branch = str(data.get("branch") or "")
|
|
target_path = str(data.get("path") or "")
|
|
grant = _authorize(
|
|
self,
|
|
method="POST",
|
|
path=path,
|
|
branch=branch,
|
|
target_path=target_path,
|
|
)
|
|
if api.content_has_secret(str(data.get("content") or "")):
|
|
raise CommitFileError("writer_secret_content_rejected", 403)
|
|
result = _atomic_writer().execute(grant, data)
|
|
return self.sendj(result)
|
|
except AuthorizationError as exc:
|
|
return _authorization_error(self, exc)
|
|
except CommitFileError as exc:
|
|
return self.sendj({"error": exc.code}, exc.status)
|
|
except PermissionError:
|
|
return self.sendj({"error": "isolated_writer_policy_rejected"}, 403)
|
|
|
|
|
|
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
|
if _reject(handler, method, path):
|
|
return True
|
|
try:
|
|
policy.validate_endpoint(method, path, api.WS, api.BARE_REPO)
|
|
return False
|
|
except PermissionError as exc:
|
|
handler.sendj({"error": str(exc)}, 403)
|
|
return True
|
|
|
|
|
|
def checkout(branch, base="main", create=False):
|
|
api.ensure_git()
|
|
target = api.branch_name(branch)
|
|
policy.validate_branch_request({"branch": target, "base": base})
|
|
exists = api.sh(["rev-parse", "--verify", target])["code"] == 0
|
|
if exists:
|
|
if api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"] != target:
|
|
api.sh(["checkout", target], True)
|
|
elif create:
|
|
api.sh(["checkout", "-b", target, base], True)
|
|
else:
|
|
raise PermissionError("isolated_writer_branch_missing")
|
|
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
|
return target
|
|
|
|
|
|
def write_file(path, content):
|
|
policy.validate_path(path, api.WS)
|
|
return _write_file(path, content)
|
|
|
|
|
|
def sync_branch(branch: str):
|
|
policy.validate_sync_branch(branch)
|
|
local = _sync_branch(branch)
|
|
remote = os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", "").strip()
|
|
if remote != "gitea-http":
|
|
raise PermissionError("isolated_writer_gitea_remote_rejected")
|
|
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
|
pushed = subprocess.run(
|
|
["git", "push", "--porcelain", remote, f"HEAD:refs/heads/{branch}"],
|
|
cwd=api.WS,
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
if pushed.returncode != 0:
|
|
raise RuntimeError("isolated_writer_gitea_push_failed")
|
|
verified = subprocess.run(
|
|
["git", "ls-remote", "--heads", remote, f"refs/heads/{branch}"],
|
|
cwd=api.WS,
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
if verified.returncode != 0:
|
|
raise RuntimeError("isolated_writer_gitea_verification_failed")
|
|
fields = verified.stdout.strip().split()
|
|
if len(fields) != 2 or fields[0] != head or fields[1] != f"refs/heads/{branch}":
|
|
raise RuntimeError("isolated_writer_gitea_oid_mismatch")
|
|
return {
|
|
"status": "verified",
|
|
"target": "authoritative-bare+gitea",
|
|
"branch": branch,
|
|
"oid": head,
|
|
"local": local.get("status"),
|
|
}
|
|
|
|
|
|
def commit(paths, msg):
|
|
policy.validate_commit_paths(paths, api.WS)
|
|
api.ensure_git()
|
|
selected = [api.relpath(p) for p in paths] if paths else ["."]
|
|
api.sh(["add", *selected], True)
|
|
policy.validate_staged_paths(api.WS)
|
|
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
|
if not api.sh(["status", "--short"])["stdout"]:
|
|
return {"status": "no_changes", "branch": branch, "sync": sync_branch(branch)}
|
|
out = api.sh(["commit", "-m", msg], True)
|
|
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
|
return {
|
|
"status": "committed",
|
|
"branch": branch,
|
|
"rev": api.sh(["rev-parse", "--short", "HEAD"], True)["stdout"],
|
|
"git": out,
|
|
"sync": sync_branch(branch),
|
|
}
|
|
|
|
|
|
def audit(actor, action, target, payload):
|
|
policy.append_audit_record(
|
|
{
|
|
"at": api.now(),
|
|
"actor": actor,
|
|
"action": action,
|
|
"target": target,
|
|
"payload": api.sanitize(payload),
|
|
}
|
|
)
|
|
|
|
|
|
def _validate_request(self, data, validate):
|
|
try:
|
|
validate(data)
|
|
return True
|
|
except PermissionError as exc:
|
|
self.sendj({"error": str(exc)}, 403)
|
|
return False
|
|
|
|
|
|
def _checked_branch(self, data):
|
|
if not _validate_request(self, data, policy.validate_branch_request):
|
|
return
|
|
return _branch(self, data)
|
|
|
|
|
|
def _checked_file(self, data):
|
|
data = dict(data)
|
|
data.setdefault("branch", policy.allowed_branch())
|
|
if not _validate_request(self, data, policy.validate_branch_request):
|
|
return
|
|
if not _validate_request(
|
|
self,
|
|
{"path": data.get("path", "")},
|
|
lambda item: policy.validate_path(item["path"], api.WS),
|
|
):
|
|
return
|
|
return _file(self, data)
|
|
|
|
|
|
def _checked_commit(self, data):
|
|
data = dict(data)
|
|
data.setdefault("branch", policy.allowed_branch())
|
|
if not _validate_request(self, data, policy.validate_branch_request):
|
|
return
|
|
return _commit(self, data)
|
|
|
|
|
|
def _checked_patch(self, data):
|
|
data = dict(data)
|
|
data["branch"] = policy.allowed_branch()
|
|
if not _validate_request(self, data, policy.validate_branch_request):
|
|
return
|
|
return _patch(self, data)
|
|
|
|
|
|
_branch, _file, _commit, _patch = api.H.branch, api.H.file, api.H.commit, api.H.patch
|
|
api.reject_non_read_http = reject_non_read_http
|
|
api.checkout, api.write_file, api.sync_branch, api.commit, api.audit = (
|
|
checkout,
|
|
write_file,
|
|
sync_branch,
|
|
commit,
|
|
audit,
|
|
)
|
|
api.H.branch, api.H.file, api.H.commit, api.H.patch = (
|
|
_checked_branch,
|
|
_checked_file,
|
|
_checked_commit,
|
|
_checked_patch,
|
|
)
|
|
api.H.do_GET = _isolated_do_get
|
|
api.H.do_POST = _isolated_do_post
|
|
|
|
if __name__ == "__main__":
|
|
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
|
api.ThreadingHTTPServer((api.HOST, api.PORT), api.H).serve_forever()
|