Files
a2a/a2a_pack/workspace.py

488 lines
15 KiB
Python

"""Workspace capability negotiation.
Agents never receive a filesystem path. They negotiate a *view* by intent::
view = await ctx.workspace.open_view(
purpose="Fix failing payment test",
hints=["payment", "checkout"],
file_types=["python"],
max_files=10,
mode=WorkspaceMode.READ_WRITE_OVERLAY,
)
for path in view.files:
content = await view.read(path)
The runtime resolves the request (semantic search + dependency graph + git
metadata + policy + optional human approval) and returns a bounded grant.
Writes are staged as :class:`WorkspacePatch` objects, never applied directly
to the host filesystem from inside the sandbox.
"""
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from datetime import datetime
from enum import Enum
from typing import Literal, Sequence
from pydantic import BaseModel, ConfigDict, NonNegativeInt, PositiveInt
class WorkspaceMode(str, Enum):
READ_ONLY = "read_only"
READ_WRITE_OVERLAY = "read_write_overlay" # writes staged as patches
READ_WRITE_DIRECT = "read_write_direct" # discouraged; needs explicit policy
class FileType(str, Enum):
PYTHON = "python"
TYPESCRIPT = "typescript"
JAVASCRIPT = "javascript"
YAML = "yaml"
JSON = "json"
TOML = "toml"
MARKDOWN = "markdown"
SQL = "sql"
SHELL = "shell"
OTHER = "other"
class FileMatch(BaseModel):
"""Result row from :meth:`WorkspaceClient.search`."""
model_config = ConfigDict(extra="forbid", frozen=True)
path: str
file_type: FileType
score: float = 0.0
summary: str | None = None
size_bytes: NonNegativeInt = 0
class WorkspaceGrant(BaseModel):
"""An approved access grant for a bounded set of files."""
model_config = ConfigDict(extra="forbid", frozen=True)
grant_id: str
purpose: str
files: tuple[FileMatch, ...]
mode: WorkspaceMode
reason: str
expires_at: datetime | None = None
requires_human_approval: bool = False
class WorkspacePatch(BaseModel):
"""A staged write. Not applied until the runtime/approver commits it."""
model_config = ConfigDict(extra="forbid", frozen=True)
grant_id: str
path: str
operation: Literal["create", "update", "delete"]
content: bytes | None = None # bytes for create/update, None for delete
class WorkspaceAccess(BaseModel):
"""Class-level workspace policy.
Use :meth:`none` for agents that do not touch any workspace, or
:meth:`dynamic` to allow capability negotiation under bounds.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
enabled: bool = False
max_files: NonNegativeInt = 0
allowed_modes: tuple[WorkspaceMode, ...] = ()
require_reason: bool = True
deny_patterns: tuple[str, ...] = ()
require_human_approval: bool = False
max_total_size_bytes: PositiveInt = 100 * 1024 * 1024
@classmethod
def none(cls) -> "WorkspaceAccess":
return cls(enabled=False)
@classmethod
def dynamic(
cls,
*,
max_files: int = 25,
allowed_modes: Sequence[WorkspaceMode] = (WorkspaceMode.READ_ONLY,),
require_reason: bool = True,
deny_patterns: Sequence[str] = (),
require_human_approval: bool = False,
max_total_size_bytes: int = 100 * 1024 * 1024,
) -> "WorkspaceAccess":
return cls(
enabled=True,
max_files=max_files,
allowed_modes=tuple(allowed_modes),
require_reason=require_reason,
deny_patterns=tuple(deny_patterns),
require_human_approval=require_human_approval,
max_total_size_bytes=max_total_size_bytes,
)
class WorkspaceDenied(PermissionError):
"""Raised when a workspace request violates the agent's policy."""
class WorkspaceView(ABC):
"""A bounded view over a granted set of files.
Returned by :meth:`WorkspaceClient.open_view`. Reads always go to the
granted view; writes return :class:`WorkspacePatch` objects that the
runtime will commit (or reject) outside the sandbox.
"""
grant: WorkspaceGrant
@property
def files(self) -> tuple[FileMatch, ...]:
return self.grant.files
@abstractmethod
async def read(self, path: str) -> bytes: ...
@abstractmethod
async def write(self, path: str, content: bytes) -> WorkspacePatch: ...
@abstractmethod
async def delete(self, path: str) -> WorkspacePatch: ...
@abstractmethod
async def patches(self) -> tuple[WorkspacePatch, ...]: ...
class WorkspaceClient(ABC):
"""Negotiation surface handed to the agent via ``ctx.workspace``.
The concrete implementation is provided by the runtime; agents must
program against this interface only.
"""
@abstractmethod
async def search(
self,
*,
query: str,
types: Sequence[FileType] = (),
limit: int = 20,
) -> list[FileMatch]: ...
@abstractmethod
async def request_access(
self,
*,
files: Sequence[FileMatch | str],
mode: WorkspaceMode,
reason: str,
purpose: str = "",
) -> WorkspaceGrant: ...
@abstractmethod
async def open_view(
self,
*,
purpose: str,
hints: Sequence[str] = (),
file_types: Sequence[FileType] = (),
max_files: int = 10,
mode: WorkspaceMode = WorkspaceMode.READ_ONLY,
reason: str | None = None,
) -> WorkspaceView: ...
@abstractmethod
async def list_grants(self) -> list[WorkspaceGrant]: ...
async def delegate(
self,
*,
audience: str,
allow_patterns: Sequence[str] = ("**",),
deny_patterns: Sequence[str] = (),
mode: WorkspaceMode = WorkspaceMode.READ_ONLY,
outputs_prefix: str | None = None,
ttl_seconds: int = 300,
) -> str:
"""Mint a signed grant token the caller can hand to ``ctx.call``.
The default implementation requires the workspace to expose
``self.bucket`` and ``self.issuer`` — override in concrete clients
that don't fit that shape.
"""
from .grants import mint_grant
bucket = getattr(self, "bucket", None) or getattr(self, "_bucket", None)
if bucket is None:
raise NotImplementedError(
"this WorkspaceClient does not expose a bucket; override delegate()"
)
issuer = getattr(self, "issuer", "self")
_, token = mint_grant(
issuer=issuer,
audience=audience,
bucket=bucket,
mode=mode,
allow_patterns=tuple(allow_patterns),
deny_patterns=tuple(deny_patterns),
outputs_prefix=outputs_prefix,
ttl_seconds=ttl_seconds,
)
return token
# ---------------------------------------------------------------------------
# Local in-memory implementation, for dev/tests.
# ---------------------------------------------------------------------------
class LocalWorkspaceView(WorkspaceView):
def __init__(
self,
grant: WorkspaceGrant,
client: "LocalWorkspaceClient",
) -> None:
self.grant = grant
self._client = client
self._patches: list[WorkspacePatch] = []
self._granted_paths = {f.path for f in grant.files}
def _check(self, path: str) -> None:
if path not in self._granted_paths:
raise WorkspaceDenied(
f"path {path!r} not in grant {self.grant.grant_id}"
)
async def read(self, path: str) -> bytes:
self._check(path)
return self._client._files[path]
async def write(self, path: str, content: bytes) -> WorkspacePatch:
if self.grant.mode is WorkspaceMode.READ_ONLY:
raise WorkspaceDenied(f"grant is read-only: {self.grant.grant_id}")
self._check(path)
op: Literal["create", "update"] = (
"create" if path not in self._client._files else "update"
)
patch = WorkspacePatch(
grant_id=self.grant.grant_id,
path=path,
operation=op,
content=content,
)
self._patches.append(patch)
return patch
async def delete(self, path: str) -> WorkspacePatch:
if self.grant.mode is WorkspaceMode.READ_ONLY:
raise WorkspaceDenied(f"grant is read-only: {self.grant.grant_id}")
self._check(path)
patch = WorkspacePatch(
grant_id=self.grant.grant_id,
path=path,
operation="delete",
content=None,
)
self._patches.append(patch)
return patch
async def patches(self) -> tuple[WorkspacePatch, ...]:
return tuple(self._patches)
class LocalWorkspaceClient(WorkspaceClient):
"""In-memory workspace for local dev and tests.
Search is naive substring match; ranking is keyword-overlap. Real
runtime implementations replace this with embeddings/dep-graph search.
Policy enforcement (:class:`WorkspaceAccess`) IS applied here so tests
cover the rejection paths.
"""
_EXT_TO_TYPE: dict[str, FileType] = {
".py": FileType.PYTHON,
".ts": FileType.TYPESCRIPT,
".tsx": FileType.TYPESCRIPT,
".js": FileType.JAVASCRIPT,
".jsx": FileType.JAVASCRIPT,
".yaml": FileType.YAML,
".yml": FileType.YAML,
".json": FileType.JSON,
".toml": FileType.TOML,
".md": FileType.MARKDOWN,
".sql": FileType.SQL,
".sh": FileType.SHELL,
}
def __init__(
self,
files: dict[str, bytes],
*,
access: WorkspaceAccess,
bucket: str = "local",
issuer: str = "local",
) -> None:
self._files: dict[str, bytes] = dict(files)
self._access = access
self._grants: dict[str, WorkspaceGrant] = {}
self._counter = 0
# Expose bucket+issuer so the default WorkspaceClient.delegate() works.
self.bucket = bucket
self.issuer = issuer
def _detect(self, path: str) -> FileType:
for ext, ft in self._EXT_TO_TYPE.items():
if path.endswith(ext):
return ft
return FileType.OTHER
def _denied(self, path: str) -> bool:
for pat in self._access.deny_patterns:
regex = re.compile(_glob_to_regex(pat))
if regex.fullmatch(path):
return True
return False
async def search(
self,
*,
query: str,
types: Sequence[FileType] = (),
limit: int = 20,
) -> list[FileMatch]:
if not self._access.enabled:
raise WorkspaceDenied("workspace disabled by policy")
type_set = set(types)
terms = [t.lower() for t in re.split(r"\W+", query) if t]
out: list[FileMatch] = []
for path, data in self._files.items():
if self._denied(path):
continue
ft = self._detect(path)
if type_set and ft not in type_set:
continue
haystack = (path + "\n" + data.decode("utf-8", errors="ignore")).lower()
score = sum(haystack.count(t) for t in terms)
if score == 0:
continue
out.append(
FileMatch(
path=path,
file_type=ft,
score=float(score),
size_bytes=len(data),
)
)
out.sort(key=lambda m: -m.score)
return out[:limit]
async def request_access(
self,
*,
files: Sequence[FileMatch | str],
mode: WorkspaceMode,
reason: str,
purpose: str = "",
) -> WorkspaceGrant:
if not self._access.enabled:
raise WorkspaceDenied("workspace disabled by policy")
if mode not in self._access.allowed_modes:
raise WorkspaceDenied(f"mode {mode.value!r} not in allowed_modes")
if self._access.require_reason and not reason.strip():
raise WorkspaceDenied("reason required by policy")
normalized: list[FileMatch] = []
for f in files:
if isinstance(f, str):
if f not in self._files:
raise WorkspaceDenied(f"unknown path: {f!r}")
normalized.append(
FileMatch(
path=f,
file_type=self._detect(f),
size_bytes=len(self._files[f]),
)
)
else:
if f.path not in self._files:
raise WorkspaceDenied(f"unknown path: {f.path!r}")
normalized.append(f)
if len(normalized) > self._access.max_files:
raise WorkspaceDenied(
f"requested {len(normalized)} files, max_files={self._access.max_files}"
)
for m in normalized:
if self._denied(m.path):
raise WorkspaceDenied(f"path denied by policy: {m.path}")
total = sum(m.size_bytes for m in normalized)
if total > self._access.max_total_size_bytes:
raise WorkspaceDenied(
f"total size {total} exceeds max_total_size_bytes"
)
self._counter += 1
grant = WorkspaceGrant(
grant_id=f"grant-{self._counter}",
purpose=purpose,
files=tuple(normalized),
mode=mode,
reason=reason,
requires_human_approval=self._access.require_human_approval,
)
self._grants[grant.grant_id] = grant
return grant
async def open_view(
self,
*,
purpose: str,
hints: Sequence[str] = (),
file_types: Sequence[FileType] = (),
max_files: int = 10,
mode: WorkspaceMode = WorkspaceMode.READ_ONLY,
reason: str | None = None,
) -> WorkspaceView:
query = " ".join([purpose, *hints])
matches = await self.search(query=query, types=file_types, limit=max_files * 3)
chosen = matches[:max_files]
grant = await self.request_access(
files=chosen,
mode=mode,
reason=reason or purpose,
purpose=purpose,
)
return LocalWorkspaceView(grant, self)
async def list_grants(self) -> list[WorkspaceGrant]:
return list(self._grants.values())
def _glob_to_regex(pattern: str) -> str:
"""Translate a simple ``foo/**/*.py`` style glob to a regex."""
out: list[str] = []
i = 0
while i < len(pattern):
c = pattern[i]
if c == "*":
if i + 1 < len(pattern) and pattern[i + 1] == "*":
out.append(".*")
i += 2
if i < len(pattern) and pattern[i] == "/":
i += 1
else:
out.append("[^/]*")
i += 1
elif c == "?":
out.append("[^/]")
i += 1
elif c == ".":
out.append(r"\.")
i += 1
else:
out.append(re.escape(c))
i += 1
return "".join(out)