307 lines
12 KiB
Python
307 lines
12 KiB
Python
"""Uncredentialed deterministic executor for governed Online Agent changes.
|
|
|
|
This process never receives Agent/Owner/Bridge credentials and never writes Git refs.
|
|
It operates only on an exchange bundle and returns hash-bound changed files + test evidence.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from dataclasses import dataclass
|
|
import errno
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
ALLOWED_PROGRAMS = {"/usr/bin/python3", "/usr/bin/git", "/bin/true", "/usr/bin/true"}
|
|
MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
|
|
MAX_FILE_BYTES = 4 * 1024 * 1024
|
|
SECRET_ENV_TOKENS = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "PRIVATE_KEY")
|
|
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,254}$")
|
|
SENSITIVE_VALUE_RE = re.compile(
|
|
r"(?i)(authorization\s*[:=]\s*bearer\s+\S+|authorization\s+bearer\s+\S+|"
|
|
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
|
)
|
|
|
|
|
|
class IsolatedExecutorError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def sha256(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def canonical(value: object) -> bytes:
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
|
|
|
|
def safe_relative(value: object) -> str:
|
|
if not isinstance(value, str) or not value or value.startswith("/"):
|
|
raise IsolatedExecutorError("PATH_INVALID")
|
|
path = Path(value)
|
|
if ".." in path.parts or "." in path.parts or any(part in {"", ".git"} for part in path.parts):
|
|
raise IsolatedExecutorError("PATH_INVALID")
|
|
return path.as_posix()
|
|
|
|
|
|
def resolve_under(root: Path, relative: str) -> Path:
|
|
root = root.resolve()
|
|
path = (root / safe_relative(relative)).resolve(strict=False)
|
|
if path == root or root not in path.parents:
|
|
raise IsolatedExecutorError("PATH_ESCAPE")
|
|
return path
|
|
|
|
|
|
def _credential_exposure(directory: Path) -> str | None:
|
|
"""Return an exposure code; EACCES is the intended credential isolation proof."""
|
|
try:
|
|
if not directory.exists():
|
|
return None
|
|
if directory.is_symlink():
|
|
return "CREDENTIAL_MOUNT_SYMLINK"
|
|
if os.access(directory, os.R_OK):
|
|
return "CREDENTIAL_MOUNT_ACCESSIBLE"
|
|
if directory.is_dir():
|
|
for child in directory.iterdir():
|
|
if child.is_file() and os.access(child, os.R_OK):
|
|
return "CREDENTIAL_FILE_ACCESSIBLE"
|
|
except PermissionError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def assert_uncredentialed() -> None:
|
|
credentials = Path("/run/credentials")
|
|
candidates = {credentials}
|
|
credentials_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
|
candidates.add(credentials_directory)
|
|
leaked = sorted(
|
|
key for key, value in os.environ.items()
|
|
if value
|
|
and (any(token in key.upper() for token in SECRET_ENV_TOKENS) or SENSITIVE_VALUE_RE.search(str(value)))
|
|
)
|
|
if leaked:
|
|
raise IsolatedExecutorError("CREDENTIAL_ENV_EXPOSED:" + ",".join(leaked))
|
|
for directory in candidates:
|
|
exposure = _credential_exposure(directory)
|
|
if exposure is not None:
|
|
raise IsolatedExecutorError(exposure)
|
|
# In the intended PrivateNetwork namespace host loopback services are unreachable.
|
|
try:
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
except OSError as exc:
|
|
if exc.errno in {errno.EACCES, errno.EPERM, errno.EAFNOSUPPORT}:
|
|
return
|
|
raise IsolatedExecutorError("NETWORK_ISOLATION_PROBE_FAILED") from exc
|
|
probe.settimeout(0.15)
|
|
try:
|
|
if probe.connect_ex(("127.0.0.1", 18892)) == 0:
|
|
raise IsolatedExecutorError("GOVERNED_BRIDGE_REACHABLE")
|
|
finally:
|
|
probe.close()
|
|
|
|
|
|
def extract_source(archive: Path, root: Path) -> None:
|
|
if archive.is_symlink() or not archive.is_file() or archive.stat().st_size > MAX_ARCHIVE_BYTES:
|
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_INVALID")
|
|
with tarfile.open(archive, "r:gz") as tf:
|
|
for member in tf.getmembers():
|
|
path = Path(member.name)
|
|
if path.is_absolute() or ".." in path.parts or ".git" in path.parts:
|
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
|
if member.issym() or member.islnk():
|
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_LINK_DENY")
|
|
tf.extractall(root, filter="data")
|
|
|
|
|
|
def run_command(item: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
|
argv = item.get("argv")
|
|
if not isinstance(argv, Sequence) or isinstance(argv, (str, bytes)) or not argv:
|
|
raise IsolatedExecutorError("COMMAND_INVALID")
|
|
argv = [str(part) for part in argv]
|
|
if argv[0] not in ALLOWED_PROGRAMS:
|
|
raise IsolatedExecutorError("COMMAND_PROGRAM_DENY")
|
|
if argv[0] == "/usr/bin/python3" and "-m" in argv:
|
|
index = argv.index("-m")
|
|
if index + 1 >= len(argv) or argv[index + 1] not in {"pytest", "py_compile"}:
|
|
raise IsolatedExecutorError("PYTHON_MODULE_DENY")
|
|
if argv[0] == "/usr/bin/git" and any(
|
|
token in {"push", "update-ref", "commit", "merge", "rebase", "reset", "clean", "branch"}
|
|
for token in argv[1:]
|
|
):
|
|
raise IsolatedExecutorError("GIT_MUTATION_DENY")
|
|
timeout = int(item.get("timeout_seconds") or 300)
|
|
if not 1 <= timeout <= 1800:
|
|
raise IsolatedExecutorError("COMMAND_TIMEOUT_INVALID")
|
|
cwd = root if item.get("cwd", ".") == "." else resolve_under(root, str(item.get("cwd")))
|
|
started = time.monotonic()
|
|
completed = subprocess.run(
|
|
argv,
|
|
cwd=cwd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=timeout,
|
|
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
|
)
|
|
result = {
|
|
"label": str(item.get("label") or "command"),
|
|
"exit_code": completed.returncode,
|
|
"stdout_sha256": sha256(completed.stdout.encode("utf-8", "replace")),
|
|
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
|
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
}
|
|
if completed.returncode != 0:
|
|
raise IsolatedExecutorError(
|
|
"COMMAND_FAILED:" + result["label"] + ":" + str(completed.returncode)
|
|
)
|
|
return result
|
|
|
|
|
|
def apply_changes(request: Mapping[str, Any], root: Path) -> list[str]:
|
|
changes = request.get("change_set")
|
|
if not isinstance(changes, list) or not changes:
|
|
raise IsolatedExecutorError("CHANGE_SET_INVALID")
|
|
changed: list[str] = []
|
|
for change in changes:
|
|
if not isinstance(change, Mapping):
|
|
raise IsolatedExecutorError("CHANGE_INVALID")
|
|
relative = safe_relative(change.get("path"))
|
|
path = resolve_under(root, relative)
|
|
current = path.read_bytes() if path.is_file() else None
|
|
expected = change.get("expected_sha256")
|
|
if expected is not None and (current is None or sha256(current) != expected):
|
|
raise IsolatedExecutorError("SOURCE_DRIFT:" + relative)
|
|
operation = change.get("operation")
|
|
if operation == "write_file":
|
|
content = change.get("content")
|
|
if not isinstance(content, str):
|
|
raise IsolatedExecutorError("WRITE_CONTENT_INVALID")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
elif operation == "replace_text":
|
|
if current is None:
|
|
raise IsolatedExecutorError("REPLACE_SOURCE_MISSING")
|
|
old = str(change.get("old"))
|
|
new = str(change.get("new"))
|
|
count = int(change.get("count", 1))
|
|
text = current.decode("utf-8")
|
|
if text.count(old) != count:
|
|
raise IsolatedExecutorError("REPLACE_COUNT_INVALID:" + relative)
|
|
path.write_text(text.replace(old, new), encoding="utf-8")
|
|
elif operation == "delete_file":
|
|
if path.exists():
|
|
path.unlink()
|
|
else:
|
|
raise IsolatedExecutorError("CHANGE_OPERATION_INVALID")
|
|
changed.append(relative)
|
|
return sorted(set(changed))
|
|
|
|
|
|
def changed_bundle(root: Path, changed: list[str]) -> list[dict[str, Any]]:
|
|
files = []
|
|
for relative in changed:
|
|
path = resolve_under(root, relative)
|
|
if path.exists():
|
|
data = path.read_bytes()
|
|
if len(data) > MAX_FILE_BYTES:
|
|
raise IsolatedExecutorError("CHANGED_FILE_TOO_LARGE:" + relative)
|
|
files.append({
|
|
"path": relative,
|
|
"deleted": False,
|
|
"sha256": sha256(data),
|
|
"content_b64": base64.b64encode(data).decode("ascii"),
|
|
})
|
|
else:
|
|
files.append({"path": relative, "deleted": True, "sha256": None, "content_b64": None})
|
|
return files
|
|
|
|
|
|
def execute_request(request_path: Path, result_path: Path) -> None:
|
|
assert_uncredentialed()
|
|
request = json.loads(request_path.read_text(encoding="utf-8"))
|
|
if request.get("schema") != "aios.isolated-execution.request.v1":
|
|
raise IsolatedExecutorError("REQUEST_SCHEMA_INVALID")
|
|
request_id = request.get("request_id")
|
|
if not isinstance(request_id, str) or not REQUEST_ID_RE.fullmatch(request_id):
|
|
raise IsolatedExecutorError("REQUEST_ID_INVALID")
|
|
source_archive = Path(str(request.get("source_archive") or ""))
|
|
if not source_archive.is_absolute():
|
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
|
expected_archive_sha = str(request.get("source_archive_sha256") or "")
|
|
if sha256(source_archive.read_bytes()) != expected_archive_sha:
|
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_SHA_MISMATCH")
|
|
with tempfile.TemporaryDirectory(prefix="aios-isolated-executor-") as temporary:
|
|
root = Path(temporary) / "workspace"
|
|
root.mkdir()
|
|
extract_source(source_archive, root)
|
|
changed = apply_changes(request, root)
|
|
commands = [
|
|
run_command(item, root)
|
|
for item in request.get("verification", [])
|
|
if isinstance(item, Mapping)
|
|
]
|
|
result = {
|
|
"schema": "aios.isolated-execution.result.v1",
|
|
"request_id": request_id,
|
|
"request_sha256": sha256(canonical(request)),
|
|
"changed_files": changed_bundle(root, changed),
|
|
"verification": commands,
|
|
"credential_isolation": {
|
|
"credential_env_absent": True,
|
|
"credential_mount_inaccessible": True,
|
|
"governed_bridge_unreachable": True,
|
|
},
|
|
}
|
|
result["result_sha256"] = sha256(canonical(result))
|
|
temporary_result = result_path.with_suffix(".tmp")
|
|
temporary_result.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")), encoding="utf-8")
|
|
os.replace(temporary_result, result_path)
|
|
|
|
|
|
def serve(root: Path) -> None:
|
|
assert_uncredentialed()
|
|
requests = root / "requests"
|
|
results = root / "results"
|
|
failed = root / "failed"
|
|
for path in (requests, results, failed):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
while True:
|
|
for request in sorted(requests.glob("*.json")):
|
|
if request.is_symlink():
|
|
continue
|
|
result = results / request.name
|
|
try:
|
|
execute_request(request, result)
|
|
request.unlink()
|
|
except Exception as exc:
|
|
(failed / request.name).write_text(
|
|
json.dumps({"error": str(exc) or type(exc).__name__}, sort_keys=True),
|
|
encoding="utf-8",
|
|
)
|
|
request.unlink(missing_ok=True)
|
|
time.sleep(0.5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--root", default="/var/lib/aios-online-agent-executor")
|
|
parser.add_argument("--request")
|
|
parser.add_argument("--result")
|
|
args = parser.parse_args()
|
|
if args.request and args.result:
|
|
execute_request(Path(args.request), Path(args.result))
|
|
else:
|
|
serve(Path(args.root))
|