blob: 0f08fe98873f67423d0e5664dc5a42ab39b9c437 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Out-of-band policy approvals. Never register administrative functions as tools.
The directory and this CLI must be outside the agent's filesystem/shell authority.
Filesystem permissions alone do not isolate an unsandboxed same-UID agent.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import time
from typing import Any
import uuid
_APPROVAL_TTL_SECONDS = 300
_MAX_PENDING = 32
def _digest(value: dict[str, Any]) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
def _check_directory(directory: Path) -> None:
if not directory.is_absolute() or not directory.is_dir() or directory.is_symlink():
raise ValueError(
"Approval directory must be an existing, absolute, non-symlink directory."
)
if os.name == "posix":
info = directory.stat()
if info.st_uid != os.getuid() or info.st_mode & 0o077:
raise PermissionError(
"Approval directory must be owned by the service user with mode 0700."
)
def _write_json(path: Path, data: dict[str, Any]) -> None:
fd, name = tempfile.mkstemp(prefix=".policy-", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(data, stream, indent=2, sort_keys=True)
stream.write("\n")
os.replace(name, path)
finally:
Path(name).unlink(missing_ok=True)
class PolicyApprovalStore:
"""Bind one decision to an exact proposal, revision, and running process."""
def __init__(self, directory: Path):
_check_directory(directory)
self.directory = directory
self._pending: dict[str, dict[str, Any]] = {}
def _remove(self, key: str) -> None:
request = self._pending.pop(key)
for suffix in ("request", "decision"):
(self.directory / f"{request['request_id']}.{suffix}.json").unlink(
missing_ok=True
)
def authorize(self, proposal: dict[str, Any]) -> dict[str, Any]:
_check_directory(self.directory)
now = time.time()
for key, request in list(self._pending.items()):
if (
request["expires_at"] <= now
or request["revision"] != proposal["revision"]
):
self._remove(key)
key = _digest(proposal)
if key not in self._pending:
if len(self._pending) >= _MAX_PENDING:
raise PermissionError(
"Too many pending policy approvals. Wait for requests to expire."
)
request = {
**proposal,
"request_id": uuid.uuid4().hex,
"created_at": now,
"expires_at": now + _APPROVAL_TTL_SECONDS,
}
_write_json(
self.directory / f"{request['request_id']}.request.json", request
)
self._pending[key] = request
request = self._pending[key]
result = {
"status": "approval_required",
"request_id": request["request_id"],
"expires_at": request["expires_at"],
"requested_changes": request["changes"],
}
decision_path = self.directory / f"{request['request_id']}.decision.json"
try:
decision = json.loads(decision_path.read_text(encoding="utf-8"))
except FileNotFoundError:
return result
# The in-memory request, not the file shown to the administrator, is
# authoritative. Tampering with that file cannot approve another change.
if not isinstance(decision, dict) or decision.get("request_digest") != _digest(
request
):
raise PermissionError("Invalid administrator decision; policy unchanged.")
if decision.get("decision") == "approve":
if time.time() >= request["expires_at"]:
self._remove(key)
return self.authorize(proposal)
self._remove(key)
result["status"] = "approved"
else:
result["status"] = "approval_rejected"
return result
def main(argv: list[str] | None = None) -> int:
"""Run only from an operator-controlled terminal or trusted management UI."""
parser = argparse.ArgumentParser(
description="Review and decide an MCP session policy request."
)
parser.add_argument("action", choices=("show", "approve", "deny"))
parser.add_argument("request_id")
parser.add_argument("--directory", required=True, type=Path)
args = parser.parse_args(argv)
_check_directory(args.directory)
if not re.fullmatch(r"[0-9a-f]{32}", args.request_id):
parser.error("Invalid request ID")
request = json.loads(
(args.directory / f"{args.request_id}.request.json").read_text(encoding="utf-8")
)
if request["request_id"] != args.request_id or request["expires_at"] <= time.time():
parser.error("Request is invalid or expired")
print(json.dumps(request, indent=2, sort_keys=True))
if args.action != "show":
_write_json(
args.directory / f"{args.request_id}.decision.json",
{
"request_digest": _digest(request),
"decision": args.action,
},
)
print(
f"Decision recorded: {args.action}. Retry the original MCP request to apply it."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())