mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 15:32:21 +02:00
feat(runtime): bind native filesystem operations to resource identities
This commit is contained in:
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
from src.agent_runtime.resources import FilesystemRoot, intersect_roots
|
||||
from src.tool_policy import ToolPolicy, build_effective_tool_policy
|
||||
from src.turn_contract import (
|
||||
FAMILY_TOOLS, canonical_tool, requested_capabilities,
|
||||
@@ -111,6 +112,9 @@ class RequestAuthority:
|
||||
block_all: bool = False
|
||||
disable_mcp: bool = False
|
||||
inherited: bool = False
|
||||
# None is only the trusted constructor's instruction to seal a workspace.
|
||||
# Persisted/child authorities always carry an explicit tuple, including ().
|
||||
resource_roots: tuple[FilesystemRoot, ...] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.request_id, str) or not self.request_id
|
||||
@@ -122,10 +126,23 @@ class RequestAuthority:
|
||||
or any(not isinstance(n, str) or canonical_tool(n) != n for n in self.denied)
|
||||
or any(type(v) is not bool for v in (self.block_all, self.disable_mcp, self.inherited))):
|
||||
raise ValueError("Malformed request authority")
|
||||
if self.resource_roots is None:
|
||||
roots = ()
|
||||
if self.workspace:
|
||||
try:
|
||||
roots = (FilesystemRoot.seal(self.workspace, owner=self.owner),)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass # An unresolved workspace grants no filesystem root.
|
||||
object.__setattr__(self, "resource_roots", roots)
|
||||
if (not isinstance(self.resource_roots, tuple)
|
||||
or any(not isinstance(r, FilesystemRoot) or (r.owner and r.owner != self.owner)
|
||||
for r in self.resource_roots)):
|
||||
raise ValueError("Malformed request resource roots")
|
||||
|
||||
@classmethod
|
||||
def empty(cls, *, owner=None, session_id=None, workspace=None):
|
||||
return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""))
|
||||
return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""),
|
||||
resource_roots=())
|
||||
|
||||
def bound_to(self, *, owner=None, session_id=None, workspace=None):
|
||||
return (self.owner == _owner(owner) and self.session_id == str(session_id or "")
|
||||
@@ -150,12 +167,15 @@ class RequestAuthority:
|
||||
if not isinstance(child, RequestAuthority):
|
||||
raise TypeError("Child authority must be server-owned RequestAuthority")
|
||||
grants = []
|
||||
roots = ()
|
||||
if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace):
|
||||
theirs = {g.tool: g for g in child.grants}
|
||||
grants = [g.intersect(theirs[g.tool]) for g in self.grants if g.tool in theirs]
|
||||
roots = intersect_roots(self.resource_roots, child.resource_roots)
|
||||
return replace(self, grants=tuple(grants), denied=self.denied | child.denied,
|
||||
block_all=self.block_all or child.block_all,
|
||||
disable_mcp=self.disable_mcp or child.disable_mcp, inherited=True)
|
||||
disable_mcp=self.disable_mcp or child.disable_mcp, inherited=True,
|
||||
resource_roots=roots)
|
||||
|
||||
def continuation(self, *, owner=None, session_id=None):
|
||||
"""A server continuation may rebind a session, never change owner/grants."""
|
||||
@@ -164,18 +184,19 @@ class RequestAuthority:
|
||||
return replace(self, session_id=str(session_id or ""), inherited=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {"version": 1, "request_id": self.request_id, "owner": self.owner,
|
||||
return {"version": 2, "request_id": self.request_id, "owner": self.owner,
|
||||
"session_id": self.session_id, "workspace": self.workspace,
|
||||
"grants": [{"tool": g.tool,
|
||||
"actions": None if g.actions is None else sorted(g.actions),
|
||||
"inputs": None if g.inputs is None else sorted(g.inputs)} for g in self.grants],
|
||||
"denied": sorted(self.denied), "block_all": self.block_all,
|
||||
"disable_mcp": self.disable_mcp, "inherited": self.inherited}
|
||||
"disable_mcp": self.disable_mcp, "inherited": self.inherited,
|
||||
"resource_roots": [r.to_dict() for r in self.resource_roots]}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if (not isinstance(value, dict) or type(value.get("version")) is not int
|
||||
or value["version"] != 1):
|
||||
or value["version"] not in {1, 2}):
|
||||
raise ValueError("Unsupported authority snapshot")
|
||||
def limits(value):
|
||||
if value is None:
|
||||
@@ -183,10 +204,14 @@ class RequestAuthority:
|
||||
if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
|
||||
raise ValueError("Malformed authority limits")
|
||||
return frozenset(value)
|
||||
roots = value["resource_roots"] if value["version"] == 2 else []
|
||||
if not isinstance(roots, list):
|
||||
raise ValueError("Malformed request resource snapshot")
|
||||
return cls(value["request_id"], value["owner"], value["session_id"], value["workspace"],
|
||||
tuple(OperationGrant(g["tool"], limits(g["actions"]), limits(g["inputs"]))
|
||||
for g in value["grants"]), limits(value["denied"]),
|
||||
value["block_all"], value["disable_mcp"], value["inherited"])
|
||||
value["block_all"], value["disable_mcp"], value["inherited"],
|
||||
tuple(FilesystemRoot.from_dict(r) for r in roots))
|
||||
|
||||
|
||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||
@@ -397,7 +422,8 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori
|
||||
parent = RequestAuthority.empty(owner=owner)
|
||||
if parent is not None:
|
||||
authority = parent.intersect(replace(authority, session_id=parent.session_id,
|
||||
workspace=parent.workspace))
|
||||
workspace=parent.workspace,
|
||||
resource_roots=parent.resource_roots))
|
||||
return _json({"task_input": [prompt, task_type, action], "authority": authority.to_dict()})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Resolve native filesystem selectors once, after operation admission.
|
||||
|
||||
Resolution produces inert bindings; the dispatcher still owns authority,
|
||||
TurnContract, security and approval gates. No remote filesystem is resolved here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
from src.agent_runtime.resources import FilesystemResource, FilesystemRoot
|
||||
from src.path_confinement import canonical_root, confine
|
||||
|
||||
|
||||
NATIVE_FILESYSTEM_TOOLS = frozenset({
|
||||
"read_file", "write_file", "edit_file", "apply_patch", "ls", "glob", "grep",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceBinding:
|
||||
role: str
|
||||
resource: FilesystemResource
|
||||
|
||||
def __post_init__(self):
|
||||
if self.role not in {"source", "target", "destination", "search_root"} or not isinstance(self.resource, FilesystemResource):
|
||||
raise ValueError("Malformed operation resource binding")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundFilesystemOperation:
|
||||
operation: ExactOperation
|
||||
execution_input: str
|
||||
bindings: tuple[ResourceBinding, ...]
|
||||
# Empty only for inert proposal resolution without an originating request.
|
||||
request_id: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.operation, ExactOperation)
|
||||
or not isinstance(self.execution_input, str)
|
||||
or not isinstance(self.bindings, tuple) or not self.bindings
|
||||
or any(not isinstance(b, ResourceBinding) for b in self.bindings)):
|
||||
raise ValueError("Malformed resource-bound operation")
|
||||
if not isinstance(self.request_id, str) or any(c in self.request_id for c in ("\0", "\n", "\r")):
|
||||
raise ValueError("Malformed resource operation request identity")
|
||||
if (self.operation.action in {"move", "rename"}
|
||||
and (len(self.bindings) != 2 or {b.role for b in self.bindings} != {"source", "destination"}
|
||||
or len({b.resource.path for b in self.bindings}) != 2
|
||||
or next(b for b in self.bindings if b.role == "source").resource.identity is None)):
|
||||
raise ValueError("Move/rename must bind distinct source and destination")
|
||||
|
||||
def validate(self):
|
||||
for binding in self.bindings:
|
||||
binding.resource.validate()
|
||||
|
||||
def to_dict(self):
|
||||
return {"request_id": self.request_id, "tool": self.operation.transport_tool, "input": self.operation.input,
|
||||
"execution_input": self.execution_input,
|
||||
"bindings": [{"role": b.role, "resource": b.resource.to_dict()} for b in self.bindings]}
|
||||
|
||||
def resolve_path(self, selector, *, search=False):
|
||||
"""Consume declared canonical targets; permit bounded search descendants."""
|
||||
if not isinstance(selector, str):
|
||||
raise ValueError("Resource selector must be a string")
|
||||
value = selector.strip()
|
||||
for binding in self.bindings:
|
||||
resource = binding.resource
|
||||
if value == resource.path or (search and not value and binding.role == "search_root"):
|
||||
resource.validate()
|
||||
return resource.path
|
||||
if not search:
|
||||
for binding in self.bindings:
|
||||
resource = binding.resource
|
||||
if binding.role == "search_root" and resource.identity.kind == "directory":
|
||||
resource.validate()
|
||||
try:
|
||||
path = confine(resource.path, value)
|
||||
return FilesystemResource.resolve(resource.root, path).path
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
continue
|
||||
raise ValueError("Path is not declared by the resource-bound operation")
|
||||
|
||||
|
||||
def _resolve(roots, selector, *, workspace, allow_missing):
|
||||
if not isinstance(selector, str) or not selector.strip():
|
||||
raise ValueError("Resource path is required and must be a string")
|
||||
value = selector.strip()
|
||||
# The virtual alias belongs to the request workspace, even when a child
|
||||
# narrows its root to a subdirectory of that workspace.
|
||||
if value == "/workspace" or value.startswith("/workspace/"):
|
||||
if not workspace:
|
||||
raise ValueError("Workspace alias has no server-owned workspace")
|
||||
base = canonical_root(workspace)
|
||||
value = base if value == "/workspace" else os.path.join(base, value[len("/workspace/"):])
|
||||
elif not os.path.isabs(os.path.expanduser(value)):
|
||||
if workspace:
|
||||
value = os.path.join(canonical_root(workspace), value)
|
||||
elif len(roots) == 1:
|
||||
value = os.path.join(roots[0].path, value)
|
||||
else:
|
||||
raise ValueError("Relative resource path has no unambiguous server root")
|
||||
for root in roots:
|
||||
try:
|
||||
return FilesystemResource.resolve(root, value, allow_missing=allow_missing)
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
continue
|
||||
boundary = "the workspace" if workspace else "the sealed roots"
|
||||
raise ValueError(f"Resource path is outside {boundary}, sensitive, missing or changed")
|
||||
|
||||
|
||||
def resolve_filesystem_operation(operation, *, roots, workspace="", request_id=""):
|
||||
"""Server adapter. This does not grant the operation or authorize its roots."""
|
||||
if not isinstance(operation, ExactOperation) or operation.tool not in NATIVE_FILESYSTEM_TOOLS:
|
||||
raise ValueError("Operation has no native filesystem adapter")
|
||||
if (not isinstance(roots, tuple) or not roots
|
||||
or any(not isinstance(r, FilesystemRoot) for r in roots)):
|
||||
raise ValueError("Native filesystem operation requires a sealed resource root")
|
||||
content = operation.input
|
||||
args = json.loads(content) if content.lstrip().startswith("{") else None
|
||||
if args is not None and not isinstance(args, dict):
|
||||
raise ValueError("Filesystem input must be an object")
|
||||
bindings = []
|
||||
|
||||
def bind(selector, role, *, missing=False):
|
||||
resource = _resolve(roots, selector, workspace=workspace, allow_missing=missing)
|
||||
bindings.append(ResourceBinding(role, resource))
|
||||
return resource.path
|
||||
|
||||
tool = operation.tool
|
||||
if tool == "apply_patch":
|
||||
from src.agent_tools.filesystem_tools import _parse_agent_patch
|
||||
if args is None:
|
||||
patch = content
|
||||
else:
|
||||
variants = [args[k] for k in ("patch_text", "patchText", "patch") if k in args]
|
||||
if not variants or any(not isinstance(p, str) or p != variants[0] for p in variants):
|
||||
raise ValueError("Patch requires one unambiguous patch_text")
|
||||
patch = variants[0]
|
||||
ops = _parse_agent_patch(patch)
|
||||
paths = [bind(op["path"], "destination" if op["kind"] == "add" else "target",
|
||||
missing=op["kind"] == "add") for op in ops]
|
||||
objects = [b.resource.identity for b in bindings if b.resource.identity is not None]
|
||||
if len(set(paths)) != len(paths) or len(set(objects)) != len(objects):
|
||||
raise ValueError("Patch targets resolve to the same resource")
|
||||
path_iter = iter(paths)
|
||||
lines = patch.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
for marker in ("*** Add File: ", "*** Update File: ", "*** Delete File: "):
|
||||
if line.startswith(marker):
|
||||
lines[i] = marker + next(path_iter)
|
||||
break
|
||||
execution_input = json.dumps({"patch_text": "\n".join(lines)}, sort_keys=True)
|
||||
else:
|
||||
search = tool in {"ls", "glob", "grep"}
|
||||
if args is None:
|
||||
if tool == "write_file":
|
||||
path, _, body = content.partition("\n")
|
||||
args = {"path": path.strip(), "content": body}
|
||||
elif tool == "edit_file":
|
||||
raise ValueError("edit_file requires a JSON object")
|
||||
elif tool in {"glob", "grep"}:
|
||||
args = {"pattern": content.strip()}
|
||||
else:
|
||||
args = {"path": content.split("\n", 1)[0].strip()}
|
||||
selector = args.get("path", "" if search else None)
|
||||
if search and selector == "":
|
||||
if workspace:
|
||||
selector = canonical_root(workspace)
|
||||
elif len(roots) == 1:
|
||||
selector = roots[0].path
|
||||
else:
|
||||
raise ValueError("Search root is unresolved")
|
||||
args["path"] = bind(selector, "search_root" if search else
|
||||
"source" if tool == "read_file" else "destination" if tool == "write_file" else "target",
|
||||
missing=tool == "write_file")
|
||||
execution_input = json.dumps(args, sort_keys=True, allow_nan=False)
|
||||
bound = BoundFilesystemOperation(operation, execution_input, tuple(bindings), request_id)
|
||||
bound.validate()
|
||||
return bound
|
||||
|
||||
|
||||
_ACTIVE: ContextVar[BoundFilesystemOperation | None] = ContextVar("resource_operation", default=None)
|
||||
|
||||
|
||||
def active_resource_operation():
|
||||
return _ACTIVE.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_resource_operation(operation):
|
||||
if operation is not None and not isinstance(operation, BoundFilesystemOperation):
|
||||
raise TypeError("Resource operation must be server-owned")
|
||||
if operation is not None:
|
||||
operation.validate()
|
||||
token = _ACTIVE.set(operation)
|
||||
try:
|
||||
yield operation
|
||||
finally:
|
||||
_ACTIVE.reset(token)
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Inert server-owned resource identities, independent of operation authority.
|
||||
|
||||
Filesystem observations detect replacement; they are not held kernel handles or
|
||||
content/effect evidence. Other producers must supply their own incarnations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
|
||||
from src.agent_runtime.path_policy import _is_sensitive_path
|
||||
from src.path_confinement import canonical_root, confine
|
||||
|
||||
|
||||
def _text(value, label, *, optional=False):
|
||||
if (not isinstance(value, str) or (not value and not optional)
|
||||
or any(c in value for c in ("\0", "\n", "\r"))):
|
||||
raise ValueError(f"Invalid resource {label}")
|
||||
|
||||
|
||||
def _absolute(value):
|
||||
_text(value, "path")
|
||||
if not os.path.isabs(value) or os.path.normpath(value) != value:
|
||||
raise ValueError("Resource path must be canonical and absolute")
|
||||
|
||||
|
||||
def _control_plane_path(path):
|
||||
# Execution snapshots/receipts are server state, even if a workspace root
|
||||
# contains the data directory. A writable user file cannot mint authority.
|
||||
from src.constants import BG_JOBS_DIR, BG_JOBS_FILE, CONTAINMENT_STATE_FILE
|
||||
if path in {canonical_root(BG_JOBS_FILE), canonical_root(CONTAINMENT_STATE_FILE)}:
|
||||
return True
|
||||
return (Path(path).is_relative_to(canonical_root(BG_JOBS_DIR))
|
||||
and path.endswith(".authority.json"))
|
||||
|
||||
|
||||
class FilesystemScope(str, Enum):
|
||||
WORKSPACE = "workspace"
|
||||
SCRATCH = "scratch"
|
||||
EXTERNAL = "external"
|
||||
PRIVATE = "private"
|
||||
|
||||
|
||||
class ResourceIdentityError(ValueError):
|
||||
"""An observed execution resource has changed or cannot be resolved."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileObjectIdentity:
|
||||
device: int
|
||||
inode: int
|
||||
kind: str
|
||||
|
||||
def __post_init__(self):
|
||||
if (type(self.device) is not int or self.device < 0
|
||||
or type(self.inode) is not int or self.inode <= 0
|
||||
or self.kind not in {"file", "directory"}):
|
||||
raise ValueError("Malformed filesystem object identity")
|
||||
|
||||
@classmethod
|
||||
def observe(cls, path):
|
||||
info = os.stat(path, follow_symlinks=False)
|
||||
kind = ("file" if stat.S_ISREG(info.st_mode) else
|
||||
"directory" if stat.S_ISDIR(info.st_mode) else None)
|
||||
if kind is None:
|
||||
raise ValueError("Filesystem resource must be a regular file or directory")
|
||||
return cls(info.st_dev, info.st_ino, kind)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilesystemRoot:
|
||||
path: str
|
||||
scope: FilesystemScope
|
||||
identity: FileObjectIdentity
|
||||
owner: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
_absolute(self.path)
|
||||
_text(self.owner, "owner", optional=True)
|
||||
if (not isinstance(self.scope, FilesystemScope)
|
||||
or not isinstance(self.identity, FileObjectIdentity)
|
||||
or self.identity.kind != "directory"
|
||||
or os.path.dirname(self.path) == self.path
|
||||
or _is_sensitive_path(self.path)
|
||||
or (self.scope is FilesystemScope.PRIVATE and not self.owner)):
|
||||
raise ValueError("Malformed filesystem root identity")
|
||||
|
||||
@classmethod
|
||||
def seal(cls, path, *, scope=FilesystemScope.WORKSPACE, owner=""):
|
||||
root = canonical_root(path)
|
||||
return cls(root, scope, FileObjectIdentity.observe(root), owner)
|
||||
|
||||
def validate(self):
|
||||
try:
|
||||
if canonical_root(self.path) != self.path or FileObjectIdentity.observe(self.path) != self.identity:
|
||||
raise ResourceIdentityError("Filesystem root identity changed")
|
||||
except (OSError, RuntimeError) as error:
|
||||
raise ResourceIdentityError("Filesystem root identity is unresolved") from error
|
||||
|
||||
def to_dict(self):
|
||||
return {**asdict(self), "scope": self.scope.value}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"path", "scope", "identity", "owner"}:
|
||||
raise ValueError("Malformed filesystem root snapshot")
|
||||
return cls(value["path"], FilesystemScope(value["scope"]),
|
||||
FileObjectIdentity(**value["identity"]), value["owner"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathObservation:
|
||||
path: str
|
||||
identity: FileObjectIdentity
|
||||
|
||||
def __post_init__(self):
|
||||
_absolute(self.path)
|
||||
if not isinstance(self.identity, FileObjectIdentity) or self.identity.kind != "directory":
|
||||
raise ValueError("Malformed filesystem ancestor identity")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilesystemResource:
|
||||
root: FilesystemRoot
|
||||
path: str
|
||||
identity: FileObjectIdentity | None
|
||||
ancestors: tuple[PathObservation, ...]
|
||||
|
||||
def __post_init__(self):
|
||||
_absolute(self.path)
|
||||
if (not isinstance(self.root, FilesystemRoot)
|
||||
or not Path(self.path).is_relative_to(self.root.path)
|
||||
or (self.identity is not None and not isinstance(self.identity, FileObjectIdentity))
|
||||
or not isinstance(self.ancestors, tuple)
|
||||
or any(not isinstance(a, PathObservation) for a in self.ancestors)
|
||||
or not self.ancestors
|
||||
or self.ancestors[0] != PathObservation(self.root.path, self.root.identity)):
|
||||
raise ValueError("Malformed filesystem resource identity")
|
||||
parent = Path(self.root.path)
|
||||
expected = [str(parent)]
|
||||
for part in Path(self.path).relative_to(self.root.path).parts[:-1]:
|
||||
parent /= part
|
||||
expected.append(str(parent))
|
||||
if ([a.path for a in self.ancestors] != expected[:len(self.ancestors)]
|
||||
or (self.identity is not None and len(self.ancestors) != len(expected))):
|
||||
raise ValueError("Malformed filesystem ancestor chain")
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, root, selector, *, allow_missing=False):
|
||||
root.validate()
|
||||
# Only this server-owned workspace root supplies the virtual alias.
|
||||
if not isinstance(selector, str):
|
||||
raise ValueError("Resource path must be a string")
|
||||
value = selector.strip()
|
||||
if root.scope is FilesystemScope.WORKSPACE:
|
||||
if value == "/workspace":
|
||||
value = root.path
|
||||
elif value.startswith("/workspace/"):
|
||||
value = os.path.join(root.path, value[len("/workspace/"):])
|
||||
path = confine(root.path, value)
|
||||
if _is_sensitive_path(path) or _control_plane_path(path):
|
||||
raise ValueError("Resource path is sensitive")
|
||||
ancestors = [PathObservation(root.path, root.identity)]
|
||||
relative = Path(path).relative_to(root.path)
|
||||
parent = Path(root.path)
|
||||
missing_parent = False
|
||||
for part in relative.parts[:-1]:
|
||||
parent /= part
|
||||
try:
|
||||
observed = FileObjectIdentity.observe(parent)
|
||||
except FileNotFoundError:
|
||||
missing_parent = True
|
||||
break
|
||||
ancestors.append(PathObservation(str(parent), observed))
|
||||
try:
|
||||
identity = None if missing_parent else FileObjectIdentity.observe(path)
|
||||
except FileNotFoundError:
|
||||
identity = None
|
||||
if identity is None and not allow_missing:
|
||||
raise ValueError("Filesystem resource is unresolved or missing")
|
||||
return cls(root, path, identity, tuple(ancestors))
|
||||
|
||||
def validate(self):
|
||||
try:
|
||||
if self.resolve(self.root, self.path, allow_missing=self.identity is None) != self:
|
||||
raise ResourceIdentityError("Filesystem resource identity changed")
|
||||
except (ValueError, OSError, RuntimeError) as error:
|
||||
raise ResourceIdentityError("Filesystem resource identity changed or is unresolved") from error
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def intersect_roots(parent, child):
|
||||
"""Keep the narrower root only when the observed parent's identity agrees."""
|
||||
result = []
|
||||
for left in parent:
|
||||
for right in child:
|
||||
if (left.scope, left.owner) != (right.scope, right.owner):
|
||||
continue
|
||||
if left == right:
|
||||
result.append(left)
|
||||
continue
|
||||
try:
|
||||
if Path(right.path).is_relative_to(left.path):
|
||||
# A newly sealed child may not renew a replaced parent root.
|
||||
left.validate()
|
||||
right.validate()
|
||||
result.append(right)
|
||||
elif Path(left.path).is_relative_to(right.path):
|
||||
left.validate()
|
||||
right.validate()
|
||||
result.append(left)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
continue
|
||||
return tuple(dict.fromkeys(result))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessResource:
|
||||
namespace: str
|
||||
incarnation: str
|
||||
owner: str
|
||||
pid: int
|
||||
start_token: str
|
||||
job_id: str = ""
|
||||
containment_id: str = ""
|
||||
namespace_pid: int | None = None
|
||||
namespace_start_token: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "incarnation", "owner", "start_token"):
|
||||
_text(getattr(self, name), name)
|
||||
for name in ("job_id", "containment_id", "namespace_start_token"):
|
||||
_text(getattr(self, name), name, optional=True)
|
||||
if (type(self.pid) is not int or self.pid <= 0
|
||||
or (self.namespace_pid is not None and
|
||||
(type(self.namespace_pid) is not int or self.namespace_pid <= 0))
|
||||
or bool(self.namespace_pid) != bool(self.namespace_start_token)):
|
||||
raise ValueError("Malformed process resource identity")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserProducer:
|
||||
namespace: str
|
||||
owner: str
|
||||
thread_id: str
|
||||
session_id: str
|
||||
incarnation: str
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "owner", "thread_id", "session_id", "incarnation"):
|
||||
_text(getattr(self, name), name)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserPageResource:
|
||||
producer: BrowserProducer
|
||||
page_id: str
|
||||
navigation_generation: int
|
||||
observed_url: str
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.producer, BrowserProducer)
|
||||
or type(self.navigation_generation) is not int or self.navigation_generation < 0):
|
||||
raise ValueError("Malformed browser page identity")
|
||||
_text(self.page_id, "page")
|
||||
_text(self.observed_url, "observed URL")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalResource:
|
||||
namespace: str
|
||||
endpoint_id: str
|
||||
server_id: str
|
||||
tool_id: str
|
||||
incarnation: str
|
||||
external: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "endpoint_id", "server_id", "tool_id", "incarnation"):
|
||||
_text(getattr(self, name), name)
|
||||
if self.external is not True:
|
||||
raise ValueError("External resource cannot attest local containment")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnedResource:
|
||||
namespace: str
|
||||
owner: str
|
||||
thread_id: str
|
||||
collection: str
|
||||
record_id: str
|
||||
revision: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "owner", "thread_id", "collection", "record_id"):
|
||||
_text(getattr(self, name), name)
|
||||
_text(self.revision, "revision", optional=True)
|
||||
+27
-1
@@ -15,7 +15,7 @@ import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
@@ -27,6 +27,9 @@ from src.tool_approval_scopes import (
|
||||
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
|
||||
from src.agent_runtime.authority import RequestAuthority
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.agent_runtime.resource_binding import BoundFilesystemOperation
|
||||
|
||||
|
||||
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
|
||||
DEFAULT_MAX_PENDING_APPROVALS = 2048
|
||||
@@ -119,6 +122,7 @@ def _binding_payload(
|
||||
effects: tuple[str, ...],
|
||||
result_integrity: str,
|
||||
request_authority: RequestAuthority | None = None,
|
||||
resource_operation=None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"owner": _normalized_owner(owner),
|
||||
@@ -140,6 +144,7 @@ def _binding_payload(
|
||||
"effects": list(effects),
|
||||
"result_integrity": str(result_integrity),
|
||||
"request_authority": request_authority.to_dict() if request_authority is not None else None,
|
||||
"resource_operation": resource_operation.to_dict() if resource_operation is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +174,8 @@ class PendingToolApproval:
|
||||
# is never displayed or treated as authorization for the sealed action.
|
||||
request_text: str = ""
|
||||
request_authority: RequestAuthority | None = None
|
||||
# Server-resolved targets at proposal time; never read from the approval UI.
|
||||
resource_operation: BoundFilesystemOperation | None = None
|
||||
|
||||
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -278,6 +285,7 @@ class ExactToolApproval:
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
request_authority=self.pending.request_authority,
|
||||
resource_operation=self.pending.resource_operation,
|
||||
)
|
||||
return _canonical_digest(expected) == self.pending.digest
|
||||
|
||||
@@ -366,6 +374,22 @@ class ToolApprovalStore:
|
||||
if request_authority is not None and not isinstance(request_authority, RequestAuthority):
|
||||
raise TypeError("Approval authority must be server-owned RequestAuthority")
|
||||
now = time.time()
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
from src.agent_runtime.resource_binding import NATIVE_FILESYSTEM_TOOLS, resolve_filesystem_operation
|
||||
from src.agent_runtime.resources import FilesystemRoot
|
||||
resource_operation = None
|
||||
if tool_name in NATIVE_FILESYSTEM_TOOLS:
|
||||
try:
|
||||
roots = request_authority.resource_roots if request_authority is not None else ()
|
||||
if not roots and workspace:
|
||||
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
|
||||
resource_operation = resolve_filesystem_operation(
|
||||
ExactOperation.normalize(tool_name, content), roots=roots, workspace=workspace or "",
|
||||
request_id=request_authority.request_id if request_authority is not None else "")
|
||||
except (ValueError, TypeError, OSError, RuntimeError):
|
||||
# An unresolved proposal may be displayed, but it cannot execute
|
||||
# after approval by reconstructing its targets at claim time.
|
||||
pass
|
||||
effects = tuple(sorted(effect.value for effect in capabilities.effects))
|
||||
result_integrity = capabilities.result_integrity.value
|
||||
payload = _binding_payload(
|
||||
@@ -384,6 +408,7 @@ class ToolApprovalStore:
|
||||
effects=effects,
|
||||
result_integrity=result_integrity,
|
||||
request_authority=request_authority,
|
||||
resource_operation=resource_operation,
|
||||
)
|
||||
pending = PendingToolApproval(
|
||||
approval_id=secrets.token_urlsafe(32),
|
||||
@@ -408,6 +433,7 @@ class ToolApprovalStore:
|
||||
continuation_query=payload["continuation_query"],
|
||||
request_text=str(request_text or ""),
|
||||
request_authority=request_authority,
|
||||
resource_operation=resource_operation,
|
||||
)
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
|
||||
+75
-4
@@ -19,7 +19,7 @@ import secrets
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterator, Optional, Tuple
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ from src.constants import (
|
||||
)
|
||||
from src.path_confinement import canonical_root, confine, is_inside
|
||||
from src.tool_utils import _truncate, get_mcp_manager
|
||||
from src.tool_types import ToolBlock
|
||||
from src.agent_runtime.resource_binding import (
|
||||
NATIVE_FILESYSTEM_TOOLS, active_resource_operation, bind_resource_operation,
|
||||
resolve_filesystem_operation,
|
||||
)
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
|
||||
|
||||
class _MissingToolSecurityContext:
|
||||
@@ -830,6 +836,9 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
When a workspace is active for this turn, paths are confined to it instead
|
||||
of the default allowlist (see _resolve_tool_path_in_workspace).
|
||||
"""
|
||||
resource_operation = active_resource_operation()
|
||||
if resource_operation is not None:
|
||||
return resource_operation.resolve_path(raw_path)
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
return _resolve_tool_path_in_workspace(ws, raw_path)
|
||||
@@ -972,6 +981,9 @@ def _resolve_search_root(raw_path: str) -> str:
|
||||
primary root (project data dir) and a supplied path is confined by the
|
||||
global allowlist + sensitive-file policy.
|
||||
"""
|
||||
resource_operation = active_resource_operation()
|
||||
if resource_operation is not None:
|
||||
return resource_operation.resolve_path(raw_path, search=True)
|
||||
raw = (raw_path or "").strip()
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
@@ -1237,6 +1249,7 @@ async def _direct_fallback(
|
||||
"disabled_tools": frozenset(disabled_tools or ()),
|
||||
"tool_policy": tool_policy,
|
||||
"request_authority": active_request_authority(),
|
||||
"resource_operation": active_resource_operation(),
|
||||
}
|
||||
|
||||
from src.agent_tools import TOOL_HANDLERS
|
||||
@@ -1364,6 +1377,41 @@ async def execute_tool_block(
|
||||
"exit_code": 1, "failure_kind": "turn_contract_denied",
|
||||
}
|
||||
|
||||
# External executors require their own adapters. Local observations must
|
||||
# never stand in for remote resource or containment identities.
|
||||
execution_bridge = get_active_execution_bridge()
|
||||
transport = operation.transport_tool
|
||||
external_resource_call = (
|
||||
(execution_bridge is not None and transport in execution_bridge.supported_tools)
|
||||
or (transport in _ROUTED_BRIDGE_TOOLS and _client_bridge(client_runtime_context) is not None)
|
||||
or (transport == "apply_patch" and _tui_host_bridge_patch_url(client_runtime_context))
|
||||
)
|
||||
resource_operation = None
|
||||
if operation.tool in NATIVE_FILESYSTEM_TOOLS and not external_resource_call:
|
||||
try:
|
||||
roots = authority.resource_roots
|
||||
approved_resource = exact_approval.pending.resource_operation if exact_approval is not None else None
|
||||
if exact_approval is not None and approved_resource is None:
|
||||
raise ValueError("Approved filesystem action has no sealed resource identity")
|
||||
if exact_admission and not roots and approved_resource is not None:
|
||||
# This single exact action can use only the roots sealed with
|
||||
# its proposal. The request/child authority is never widened.
|
||||
roots = tuple(dict.fromkeys(b.resource.root for b in approved_resource.bindings))
|
||||
if any(r.owner and r.owner != authority.owner for r in roots):
|
||||
raise ValueError("Filesystem resource owner differs from request authority")
|
||||
resource_operation = resolve_filesystem_operation(
|
||||
operation, roots=roots, workspace=authority.workspace, request_id=authority.request_id)
|
||||
if approved_resource is not None:
|
||||
if approved_resource.request_id and approved_resource.request_id != authority.request_id:
|
||||
raise ValueError("Approved resource belongs to another request")
|
||||
if replace(resource_operation, request_id=approved_resource.request_id) != approved_resource:
|
||||
raise ValueError("Approved filesystem resource identity changed")
|
||||
except (ValueError, TypeError, OSError, RuntimeError) as error:
|
||||
return f"{transport}: BLOCKED", {
|
||||
"error": str(error), "exit_code": 1, "blocked": True,
|
||||
"failure_kind": "resource_identity_denied",
|
||||
}
|
||||
|
||||
approval_claimed = False
|
||||
if exact_approval is not None:
|
||||
if (
|
||||
@@ -1450,9 +1498,9 @@ async def execute_tool_block(
|
||||
|
||||
token = _active_workspace.set(workspace or None)
|
||||
try:
|
||||
with bind_request_authority(authority):
|
||||
with bind_request_authority(authority), bind_resource_operation(resource_operation):
|
||||
output = await _execute_tool_block_impl(
|
||||
block,
|
||||
ToolBlock(transport, resource_operation.execution_input) if resource_operation is not None else block,
|
||||
session_id=session_id,
|
||||
disabled_tools=disabled_tools,
|
||||
owner=owner,
|
||||
@@ -1483,6 +1531,11 @@ async def execute_tool_block(
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
return output
|
||||
except ResourceIdentityError as error:
|
||||
return f"{transport}: BLOCKED", {
|
||||
"error": str(error), "exit_code": 1, "blocked": True,
|
||||
"failure_kind": "resource_identity_denied",
|
||||
}
|
||||
finally:
|
||||
_active_workspace.reset(token)
|
||||
|
||||
@@ -1614,6 +1667,7 @@ async def _execute_tool_block_impl(
|
||||
bridge_owns_tool = (
|
||||
execution_bridge is not None
|
||||
and tool in execution_bridge.supported_tools
|
||||
and active_resource_operation() is None
|
||||
)
|
||||
|
||||
# Public-owner restrictions protect tools executed by this deployment.
|
||||
@@ -1673,7 +1727,8 @@ async def _execute_tool_block_impl(
|
||||
},
|
||||
)
|
||||
|
||||
if tool in _ROUTED_BRIDGE_TOOLS and _client_bridge(client_runtime_context) is not None:
|
||||
if (active_resource_operation() is None and tool in _ROUTED_BRIDGE_TOOLS
|
||||
and _client_bridge(client_runtime_context) is not None):
|
||||
return await dispatched(_route_tool_via_bridge(tool, content, session_id, client_runtime_context))
|
||||
|
||||
# Background execution: a `bash` block whose first line is the `#!bg`
|
||||
@@ -1719,6 +1774,22 @@ async def _execute_tool_block_impl(
|
||||
from src.ai_interaction import do_generate_image
|
||||
desc = "generate_image"
|
||||
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner))
|
||||
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
||||
and (active_resource_operation() is not None or tool != "apply_patch"
|
||||
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
||||
if active_resource_operation() is None:
|
||||
return f"{tool}: BLOCKED", {
|
||||
"error": "Native filesystem dispatch has no bound resource operation",
|
||||
"exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied",
|
||||
}
|
||||
# Backend selection is pinned. MCP connection availability cannot
|
||||
# redirect an admitted native resource to a different filesystem.
|
||||
original = active_resource_operation().operation.input
|
||||
desc = f"{tool}: {original.split(chr(10))[0][:80]}"
|
||||
result = await dispatched(_direct_fallback(tool, content, owner=owner, session_id=session_id)) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
if tool == "edit_file":
|
||||
desc = result.get("output") or result.get("error") or "edit_file"
|
||||
elif tool in _MCP_TOOL_MAP:
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
|
||||
Reference in New Issue
Block a user