diff --git a/.env.example b/.env.example index 66305a090..bf54fc165 100644 --- a/.env.example +++ b/.env.example @@ -111,17 +111,35 @@ LLM_MAX_TOKENS=16384 # ============================================================================= # MCP servers are configured in mcp-servers.json # Available servers: -# - github: Official GitHub MCP (requires Podman/Docker) -# - gitmcp: GitMCP.io for repo documentation -# - atlassian: Official Atlassian Rovo MCP (uses JIRA_* credentials above) +# - github: GitHub's remote MCP endpoint +# - atlassian: Locally hosted mcp-atlassian SSE endpoint # - context7: Upstash Context7 for library docs # # Enable MCP server integrations AGENT_ENABLE_MCP=true # MCP servers to enable: '*' for all from mcp-servers.json, or comma-separated list AGENT_MCP_SERVERS=* -# Restrict MCP tools to read-only operations (no create/update/delete) -AGENT_MCP_READ_ONLY=true +# Exact tools granted to host agents, as server:tool names. Empty is deny-all. +# Tool names vary by server version and account permissions; run `forge mcp-tools` +# against your deployment and remove any tool that is not required. +# +# Read-only tools commonly exposed by the servers in mcp-servers.json: +# github (default toolsets): +# get_me, get_file_contents, get_commit, list_branches, list_commits, +# list_releases, list_tags, search_code, search_repositories, issue_read, +# list_issues, search_issues, pull_request_read, search_pull_requests, +# search_users +# atlassian (core mcp-atlassian toolsets): +# jira_get_issue, jira_search, jira_get_project_issues, +# jira_batch_get_changelogs, jira_search_fields, jira_get_field_options, +# jira_get_transitions, confluence_search, confluence_get_page, +# confluence_get_page_children, confluence_get_page_history, +# confluence_get_page_diff, confluence_get_comments +# context7: +# resolve-library-id, query-docs +# +# Copy-ready minimal baseline for issue/code/documentation lookup: +AGENT_MCP_ALLOWED_TOOLS=github:get_me,github:get_file_contents,github:search_code,github:issue_read,github:pull_request_read,atlassian:jira_get_issue,atlassian:jira_search,context7:resolve-library-id,context7:query-docs # Path to MCP servers config file (default: mcp-servers.json in project root) AGENT_MCP_CONFIG_PATH= @@ -130,10 +148,10 @@ AGENT_MCP_CONFIG_PATH= # ============================================================================= # Enable agent tools (file operations, search, etc.) AGENT_ENABLE_TOOLS=true -# Allowed tools: '*' for all, or comma-separated list (Read,Write,Edit,Glob,Grep,Bash,WebSearch) -AGENT_ALLOWED_TOOLS=* -# Working directory for agent file operations (empty = current directory) -AGENT_WORKING_DIRECTORY= +# Safe host built-ins only. Write and shell tools are always prohibited. +AGENT_ALLOWED_TOOLS=ls,read_file,glob,grep +# Dedicated virtual filesystem root (deployments should use /var/lib/forge/agent). +AGENT_ROOT_DIR=.forge/agent # Backend type: filesystem, state, or store AGENT_BACKEND=filesystem diff --git a/Dockerfile b/Dockerfile index bf7052251..0c36bd6a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Copy wheels and install COPY --from=builder /wheels /wheels -RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels +COPY skills/ /app/skills/ +RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels && \ + mkdir -p /var/lib/forge/agent && chown -R forge:forge /var/lib/forge # Switch to non-root user USER forge diff --git a/charts/forge/templates/api.yaml b/charts/forge/templates/api.yaml index b37756d3e..10a7f3ed6 100644 --- a/charts/forge/templates/api.yaml +++ b/charts/forge/templates/api.yaml @@ -34,8 +34,10 @@ spec: envFrom: - secretRef: name: {{ .Values.existingSecret }} - {{- if .Values.redis.enabled }} env: + - name: AGENT_ROOT_DIR + value: /var/lib/forge/agent + {{- if .Values.redis.enabled }} - name: REDIS_URL value: redis://{{ include "forge.fullname" . }}-redis:6379/0 {{- end }} @@ -48,6 +50,12 @@ spec: httpGet: {path: /api/v1/live, port: http} resources: {{- toYaml .Values.resources.api | nindent 12 }} + volumeMounts: + - name: agent-root + mountPath: /var/lib/forge + volumes: + - name: agent-root + emptyDir: {} --- apiVersion: v1 kind: Service diff --git a/charts/forge/templates/worker.yaml b/charts/forge/templates/worker.yaml index ce55f5861..6d9543e7c 100644 --- a/charts/forge/templates/worker.yaml +++ b/charts/forge/templates/worker.yaml @@ -49,6 +49,8 @@ spec: - secretRef: name: {{ .Values.existingSecret }} env: + - name: AGENT_ROOT_DIR + value: /var/lib/forge/agent {{- if .Values.redis.enabled }} - name: REDIS_URL value: redis://{{ include "forge.fullname" . }}-redis:6379/0 @@ -103,6 +105,8 @@ spec: - name: metrics containerPort: {{ .Values.worker.metricsPort }} volumeMounts: + - name: agent-root + mountPath: /var/lib/forge - name: workspaces mountPath: {{ .Values.workspace.mountPath }} {{- if .Values.googleCredentials.enabled }} @@ -114,6 +118,8 @@ spec: resources: {{- toYaml .Values.resources.worker | nindent 12 }} volumes: + - name: agent-root + emptyDir: {} - name: workspaces persistentVolumeClaim: claimName: {{ .Values.workspace.claimName }} diff --git a/containers/entrypoint.py b/containers/entrypoint.py index 46f4a350c..faa86f606 100644 --- a/containers/entrypoint.py +++ b/containers/entrypoint.py @@ -43,6 +43,39 @@ logger = logging.getLogger(__name__) +def _operational_shell_env() -> dict[str, str]: + """Environment exposed to commands run by implementation/reviewer agents. + + This prevents ordinary subprocess inheritance of provider and tracing secrets. + It is defense in depth only: secrets in the container's top-level environment + may still be obtainable by sufficiently capable code through facilities such + as ``/proc``. + """ + names = ( + "PATH", + "HOME", + "LANG", + "LANGUAGE", + "LC_ALL", + "LC_CTYPE", + "TMPDIR", + "TMP", + "TEMP", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_USER_NAME", + "GIT_USER_EMAIL", + ) + result = {name: os.environ[name] for name in names if name in os.environ} + result.setdefault("PATH", os.defpath) + result.setdefault("HOME", str(Path.home())) + result.setdefault("LANG", "C.UTF-8") + result.setdefault("TMPDIR", "/tmp") + return result + + def command_timeout(default: int) -> int: """Return the configured per-command timeout, or the caller's default.""" raw_value = os.environ.get("CONTAINER_COMMAND_TIMEOUT") @@ -633,7 +666,8 @@ async def run_agent_task( backend = LocalShellBackend( root_dir=str(workspace), - inherit_env=True, + inherit_env=False, + env=_operational_shell_env(), virtual_mode=False, timeout=command_timeout(600), ) @@ -742,7 +776,8 @@ async def run_reviewer_agent( backend = LocalShellBackend( root_dir=str(workspace), - inherit_env=True, + inherit_env=False, + env=_operational_shell_env(), virtual_mode=False, timeout=command_timeout(600), ) diff --git a/docker-compose.yml b/docker-compose.yml index fe882d95b..6e7a612e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: environment: - REDIS_URL=redis://redis:6379/0 - LOG_LEVEL=INFO + - AGENT_ROOT_DIR=/var/lib/forge/agent env_file: - .env depends_on: diff --git a/docs/reference/config.md b/docs/reference/config.md index 9b323ea27..a7fb21c2e 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -2,6 +2,9 @@ All configuration is via environment variables in `.env`. See `.env.example` in the repository for the complete list with comments. +For production agent-root, tool, MCP, container, and secret-handling requirements, see +the [safe agent deployment guide](../security/agent-isolation.md). + ## Required Variables ### Jira diff --git a/docs/security/agent-isolation.md b/docs/security/agent-isolation.md new file mode 100644 index 000000000..09de055b6 --- /dev/null +++ b/docs/security/agent-isolation.md @@ -0,0 +1,244 @@ +# Safe agent deployment + +This guide describes the security boundary implemented for Forge host agents and +container agents, and the minimum controls operators should apply in production. + +## Security model + +Forge uses two distinct agent environments: + +- **Host agents** generate plans and orchestration artifacts. They have a dedicated + virtual filesystem, read-only tools, no shell, and default-deny MCP access. +- **Container agents** implement and review code in ephemeral sandboxes. They retain + the container's model, tracing, Git, and skill configuration, but shell subprocesses + receive only an explicit operational environment. + +The container control is defense in depth, not a complete secret boundary. Provider +credentials and tracing secrets still exist in the top-level container environment in +this phase. Code capable of inspecting other processes or `/proc` may be able to read +them. Do not treat `inherit_env=False` alone as credential isolation. + +## Required versions + +Use the committed dependency lock. Host-agent permissions and tool filtering depend on +the pinned Deep Agents 0.6.x API. Build from a reviewed commit and pin deployed images +by digest; do not use mutable tags such as `latest` in production. + +## Host-agent configuration + +Set a dedicated agent root outside the Forge source tree in deployments: + +```dotenv +AGENT_ROOT_DIR=/var/lib/forge/agent +AGENT_ALLOWED_TOOLS=ls,read_file,glob,grep +AGENT_MCP_ALLOWED_TOOLS= +``` + +The development default is `.forge/agent`. Forge rejects roots that equal or contain +the source tree, task workspace root, `.env`, `.git`, or common SSH, AWS, Google Cloud, +and GitHub credential directories. It also rejects a symlink as the root. + +Create the deployment directory for the Forge service user: + +```bash +install -d -m 0700 -o forge -g forge /var/lib/forge/agent +``` + +Never mount source, task workspaces, environment files, container-engine sockets, or +credentials beneath `AGENT_ROOT_DIR`. + +At startup Forge rebuilds committed skills in `AGENT_ROOT_DIR/committed-skills`, +preserving the existing `default/` and `/` layout. Rebuilding removes +skills deleted from the deployed source. The trusted skill installer writes +runtime-fetched project skills under `AGENT_ROOT_DIR/skills`; these remain separate +from the committed tree. Host agents receive virtual, read-only access to both trees. +Skill trees with symlinks or paths escaping their source are rejected. + +### Built-in tools + +The supported host tools are exactly `ls`, `read_file`, `glob`, and `grep`. +`write_file`, `edit_file`, and `execute` are prohibited regardless of configuration. +Unknown names and `AGENT_ALLOWED_TOOLS=*` fail validation. A Deep Agents filesystem +permission denies all writes as a second enforcement layer. + +## MCP configuration + +MCP tools are denied unless their exact `server:tool` identifier appears in +`AGENT_MCP_ALLOWED_TOOLS`. Selecting a server with `AGENT_MCP_SERVERS` does not grant +its tools. + +Discover tools without enabling them: + +```bash +forge mcp-tools +``` + +Review actual behavior, then grant only required identifiers: + +```dotenv +AGENT_MCP_SERVERS=github,atlassian +AGENT_MCP_ALLOWED_TOOLS=github:get_issue,atlassian:get_issue +``` + +Avoid write-capable MCP tools for host agents. Exact allowlisting replaces the old +name heuristic: Forge does not infer safety from names such as `get`, `list`, or +`read`. MCP identifiers whose bare tool name matches any host built-in are rejected. +This prevents an MCP implementation from shadowing either a read-only filesystem +tool (`ls`, `read_file`, `glob`, or `grep`) or a prohibited tool (`write_file`, +`edit_file`, or `execute`). + +Local stdio MCP servers receive only operational environment values plus values +explicitly declared in that server's `env` configuration. Put only the credential +required by that server in its explicit environment. Prefer a remote endpoint with +independently scoped authentication where practical. + +## Container-agent configuration + +Implementation and reviewer agents use `LocalShellBackend(inherit_env=False)`. Their +ordinary shell commands receive only path/home, locale, temporary-directory, and Git +identity variables. + +The top-level container environment remains unchanged so model construction, Google +ADC, Langfuse callbacks, trace/session correlation, Git, and dynamic skills continue +to work. Google ADC remains mounted for Vertex AI tasks. Treat these sandboxes as +credential-bearing and use narrowly scoped service accounts. + +Use the narrowest network mode compatible with the task and restrict outbound traffic +to approved registries and source-control endpoints. Never mount the Podman socket, +host root, Forge credentials, or unrelated host directories into task containers. + +## Rootless Podman deployment + +The worker in this repository launches rootless Podman directly on its host: + +1. Run Forge and Podman as an unprivileged dedicated user. +2. Never expose a rootful Podman or Docker socket to Forge. +3. Keep task workspaces separate from `AGENT_ROOT_DIR`. +4. Mount only the workspace read-write and generated task file read-only. +5. Keep SELinux labeling enabled where available. +6. Configure CPU, memory, command, and overall container timeouts. +7. Disable container preservation normally: + + ```dotenv + FORGE_CONTAINER_KEEP=false + ``` + +8. Remove abandoned containers, images, and workspaces using an audited maintenance + job. Never use broad recursive deletion against unresolved paths. +9. Restrict worker-log access because failures may contain sensitive context despite + redaction. + +Build and identify the sandbox image before starting a worker: + +```bash +podman build -t registry.example.com/forge-sandbox:VERSION \ + -f containers/Containerfile containers/ +podman inspect registry.example.com/forge-sandbox:VERSION --format '{{.Digest}}' +``` + +Set `CONTAINER_IMAGE` to the reviewed immutable reference and verify it is available +to the worker user. + +## Forge service container + +The service image includes default skills and creates `/var/lib/forge/agent` for the +unprivileged `forge` user. With a read-only root filesystem, mount a dedicated writable +volume only there: + +```yaml +services: + forge-api: + read_only: true + tmpfs: + - /tmp:size=256m,mode=1777 + volumes: + - forge-agent:/var/lib/forge/agent + environment: + AGENT_ROOT_DIR: /var/lib/forge/agent + AGENT_ALLOWED_TOOLS: ls,read_file,glob,grep + AGENT_MCP_ALLOWED_TOOLS: "" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + forge-agent: {} +``` + +Keep credentials in the deployment platform's secret store, not images or committed +`.env` files. Restrict each secret to the component that needs it, rotate regularly, +and use read-only or repository-scoped tokens where possible. + +## Kubernetes requirements + +Forge includes a Kubernetes Job-based sandbox driver. Before enabling it, require: + +The bundled Helm chart mounts a separate ephemeral `emptyDir` at `/var/lib/forge` for +each API and worker pod. Forge creates its private, user-owned `agent/` subdirectory +inside that volume on startup. The runtime skill tree is rebuilt on pod startup; do +not place task workspaces or credentials in that volume. + +- distinct service accounts and no automatic API-token mount for task pods; +- the Restricted Pod Security Standard; +- `runAsNonRoot`, read-only root filesystems, dropped capabilities, seccomp + `RuntimeDefault`, and `allowPrivilegeEscalation: false`; +- ephemeral per-task storage with only the workspace writable; +- default-deny ingress and egress NetworkPolicies with explicit destinations; +- CPU, memory, ephemeral-storage, active-deadline, and termination limits; +- no host namespaces, host paths, privileged mode, or runtime sockets; +- no task-pod secrets when a gateway can perform the authenticated operation; +- cleanup and audit coverage for failed and timed-out jobs. + +Run the environment-inheritance, skill, MCP, tracing, and workflow tests against every +sandbox driver before rollout. + +## Pre-deployment validation + +Run formatting, unit, workflow, sandbox, tracing, and image-build checks from the +reviewed commit. At minimum: + +```bash +uv run ruff check src containers +uv run pytest tests/unit/integrations/agents +uv run pytest tests/unit/containers +uv run pytest tests/unit/sandbox +forge mcp-tools +``` + +Then perform negative tests outside production: + +- absolute paths and `..` cannot read outside the virtual root; +- symlink roots and symlinked skill content are rejected; +- `.env`, source metadata, credentials, and task workspaces are not host-readable; +- every host write is denied, and execution/write tools are absent; +- MCP is default-deny and only exact identifiers are exposed; +- MCP subprocesses do not inherit unrelated secrets; +- implementation and reviewer commands cannot read provider or Langfuse variables + through ordinary environment access; +- builds still find executables, home, temporary storage, and Git identity; +- model authentication, ADC mounts, Langfuse traces, session correlation, and trace + flushing still work at the container level. + +Canary one worker. Validate planning, implementation, review, build, Git, and Langfuse +workflows, monitor denials, then expand gradually. + +## Incident response + +If isolation may have failed: + +1. Stop the affected worker and prevent new tasks. +2. Preserve relevant logs and metadata under your evidence policy. +3. Revoke every credential available to the affected process or container. +4. Remove retained sandboxes and workspaces after evidence collection. +5. Audit MCP, source-control, Jira, provider, and tracing activity. +6. Correct the boundary and repeat negative tests before restoring service. + +## Deferred security work + +Separate follow-ups must: + +- remove provider credentials and Google ADC through a model gateway; +- preserve Langfuse through a credential-free proxy or collector; +- restrict process inspection and reduce the top-level environment; +- tighten dynamic-skill trust, review, and pinning policies. diff --git a/pyproject.toml b/pyproject.toml index cef77edf5..f576c3204 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "uvicorn[standard]>=0.32.0", "redis>=5.0.0", "anthropic[vertex]>=0.40.0", - "deepagents>=0.1.0", + "deepagents==0.6.12", "langchain-anthropic>=0.3.0", "langchain-google-genai>=4.0.0", "langchain-google-vertexai>=2.0.0", diff --git a/src/forge/cli.py b/src/forge/cli.py index ab81caa31..ea77b0021 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -1330,6 +1330,16 @@ async def cmd_version(_args: argparse.Namespace) -> int: return 0 +async def cmd_mcp_tools(_args: argparse.Namespace) -> int: + """Discover configured MCP tools without enabling them for an agent.""" + from forge.integrations.agents.agent import ForgeAgent + + tools = await ForgeAgent().discover_mcp_tools() + for tool in tools: + print(tool) + return 0 + + def main(argv: list[str] | None = None) -> int: """Main CLI entry point.""" parser = argparse.ArgumentParser( @@ -1457,6 +1467,11 @@ def main(argv: list[str] | None = None) -> int: help="Print the installed Forge package version", ) + subparsers.add_parser( + "mcp-tools", + help="Discover exact MCP server:tool names without enabling them", + ) + # test-skill subparser group test_skill_parser = subparsers.add_parser( "test-skill", @@ -1827,6 +1842,7 @@ def main(argv: list[str] | None = None) -> int: "logs": cmd_logs, "smoke-test": cmd_smoke_test, "version": cmd_version, + "mcp-tools": cmd_mcp_tools, "project-setup": cmd_project_setup, "get-config": cmd_get_config, } diff --git a/src/forge/config.py b/src/forge/config.py index 5d5572b26..3ba0af484 100644 --- a/src/forge/config.py +++ b/src/forge/config.py @@ -388,8 +388,8 @@ def model_policy_resolver(self): description="Enable agent tools (Read, Glob, Grep, WebSearch)", ) agent_allowed_tools: str = Field( - default="*", - description="Allowed agent tools: '*' for all, or comma-separated list", + default="ls,read_file,glob,grep", + description="Exact allowlist of safe host agent built-in tools", ) agent_enable_mcp: bool = Field( default=True, @@ -399,9 +399,9 @@ def model_policy_resolver(self): default="*", description="MCP servers to enable: '*' for all from config, or comma-separated list", ) - agent_mcp_read_only: bool = Field( - default=True, - description="Restrict MCP tools to read-only operations (no create/update/delete)", + agent_mcp_allowed_tools: str = Field( + default="", + description="Exact MCP tool allowlist using server:tool names (empty denies all)", ) agent_mcp_config_path: str = Field( default="", @@ -411,6 +411,10 @@ def model_policy_resolver(self): default="", description="Working directory for agent file operations (empty = current dir)", ) + agent_root_dir: str = Field( + default=".forge/agent", + description="Isolated root exposed to host agents", + ) skills_dir: str = Field( default="skills/", description="Base directory for skill resolution. The resolver finds skills/default/ and skills/{project}/ under this path.", @@ -422,8 +426,13 @@ def model_policy_resolver(self): @property def skills_install_dir(self) -> Path: - """Directory for runtime-fetched skill packages.""" - return Path(self.skills_dir).resolve() + """Isolated runtime directory for installed skill packages.""" + return Path(self.agent_root_dir).resolve() / "skills" + + @property + def committed_skills_dir(self) -> Path: + """Isolated snapshot of committed default and project skills.""" + return Path(self.agent_root_dir).resolve() / "committed-skills" container_langchain_verbose: bool = Field( default=False, diff --git a/src/forge/integrations/agents/agent.py b/src/forge/integrations/agents/agent.py index cd7c9b6cc..79c9b386d 100644 --- a/src/forge/integrations/agents/agent.py +++ b/src/forge/integrations/agents/agent.py @@ -17,6 +17,7 @@ from deepagents import create_deep_agent from deepagents.backends.filesystem import FilesystemBackend +from deepagents.middleware.filesystem import FilesystemPermission from langchain_anthropic import ChatAnthropic from langgraph.checkpoint.memory import MemorySaver @@ -31,6 +32,14 @@ HAS_MCP = False from forge.config import Settings, get_settings +from forge.integrations.agents.security import ( + PROHIBITED_BUILTIN_TOOLS, + SAFE_BUILTIN_TOOLS, + HostToolAllowlistMiddleware, + operational_subprocess_env, + parse_host_tools, + validate_agent_root, +) from forge.integrations.langfuse import get_langfuse_config, get_langfuse_context from forge.integrations.langfuse.fields import resolve_trace_fields from forge.model_policy import resolve_model_target_for_project @@ -56,7 +65,7 @@ HAS_ANTHROPIC_VERTEX = False # Project root directory -PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent +PROJECT_ROOT = Path(os.environ.get("FORGE_PROJECT_ROOT", Path.cwd())).resolve() # Default MCP servers config file locations (checked in order) MCP_CONFIG_PATHS = [ @@ -269,9 +278,11 @@ def _get_skill_paths(self, ticket_key: str | None = None) -> list[str]: Resolves per-project skill overrides under settings.skills_dir, with fallback to skills/default/ for any skill not overridden by the project. """ - skills_dir = PROJECT_ROOT / self.settings.skills_dir.rstrip("/") + root_dir = self._get_root_dir() paths = resolve_skill_paths( - ticket_key or "", skills_dir, skills_install_dir=self.settings.skills_install_dir + ticket_key or "", + root_dir / "committed-skills", + skills_install_dir=root_dir / "skills", ) logger.debug(f"Using skill paths: {paths}") return paths @@ -286,12 +297,7 @@ def _get_allowed_tools(self) -> list[str] | None: logger.debug("Agent tools disabled via config") return [] - allowed = self.settings.agent_allowed_tools.strip() - if allowed == "*": - logger.debug("All agent tools allowed") - return None # None means all tools - - tools = [t.strip() for t in allowed.split(",") if t.strip()] + tools = sorted(parse_host_tools(self.settings.agent_allowed_tools)) logger.debug(f"Allowed agent tools: {tools}") return tools @@ -301,35 +307,11 @@ def _get_root_dir(self) -> Path: Returns: Path to root directory. """ - if self.settings.agent_working_directory: - return Path(self.settings.agent_working_directory) - return PROJECT_ROOT - - # Write operation patterns to filter out in read-only mode - WRITE_TOOL_PATTERNS = ( - # Prefixes - "create", - "add", - "update", - "delete", - "remove", - "push", - "merge", - "fork", - "assign", - "edit", - "transition", - "close", - "reopen", - "comment", - "reply", - "approve", - "reject", - "request", - "run", - ) - # Suffixes that indicate write operations - WRITE_TOOL_SUFFIXES = ("_write",) + return validate_agent_root( + Path(self.settings.agent_root_dir), + PROJECT_ROOT, + getattr(self.settings, "workspace_base_dir", ""), + ) def _wrap_tool_with_error_handling(self, tool: Any) -> Any: """Wrap a tool to catch errors and return them as messages. @@ -391,7 +373,7 @@ def wrapped_sync(*args: Any, **kwargs: Any) -> str: logger.warning(f"Could not wrap tool {tool_name} - no func or coroutine found") return tool - async def _load_mcp_tools(self) -> list[Any]: + async def _load_mcp_tools(self, *, discovery: bool = False) -> list[Any]: """Load tools from configured MCP servers. Returns: @@ -412,12 +394,30 @@ async def _load_mcp_tools(self) -> list[Any]: # Load each server independently so a single failing server does not # prevent tools from the other servers from loading. all_tools: list[Any] = [] + allowed = { + item.strip() + for item in self.settings.agent_mcp_allowed_tools.split(",") + if item.strip() + } + collisions = { + identifier + for identifier in allowed + if identifier.rpartition(":")[2] in SAFE_BUILTIN_TOOLS | PROHIBITED_BUILTIN_TOOLS + } + if collisions: + names = ", ".join(sorted(collisions)) + raise ValueError(f"MCP tool names collide with prohibited host tools: {names}") for server_name, server_config in mcp_config.items(): try: client = MultiServerMCPClient({server_name: server_config}) server_tools = await client.get_tools() logger.info(f"Loaded {len(server_tools)} tools from MCP server '{server_name}'") - all_tools.extend(server_tools) + for tool in server_tools: + exact_name = f"{server_name}:{tool.name}" + if discovery: + all_tools.append((exact_name, tool)) + elif exact_name in allowed: + all_tools.append(tool) except Exception as e: logger.warning( f"Failed to load MCP tools from server '{server_name}' " @@ -430,54 +430,18 @@ async def _load_mcp_tools(self) -> list[Any]: logger.info(f"Loaded {len(all_tools)} tools from MCP servers") - # Filter to read-only tools if configured - if self.settings.agent_mcp_read_only: - all_tools = self._filter_read_only_tools(all_tools) - # Wrap tools with error handling to prevent crashes + if discovery: + return all_tools all_tools = [self._wrap_tool_with_error_handling(t) for t in all_tools] logger.debug(f"Wrapped {len(all_tools)} MCP tools with error handling") return all_tools - def _filter_read_only_tools(self, tools: list[Any]) -> list[Any]: - """Filter tools to only read-only operations. - - Args: - tools: List of MCP tools. - - Returns: - Filtered list with write operations removed. - """ - read_only_tools = [] - excluded_count = 0 - - for tool in tools: - name = tool.name if hasattr(tool, "name") else str(tool) - name_lower = name.lower() - - # Check if tool name matches write patterns - is_write_tool = ( - # Starts with or contains write prefix - any( - name_lower.startswith(prefix) or f"_{prefix}" in name_lower - for prefix in self.WRITE_TOOL_PATTERNS - ) - # Or ends with write suffix - or any(name_lower.endswith(suffix) for suffix in self.WRITE_TOOL_SUFFIXES) - ) - - if is_write_tool: - excluded_count += 1 - logger.debug(f"Excluding write tool: {name}") - else: - read_only_tools.append(tool) - - logger.info( - f"MCP read-only mode: kept {len(read_only_tools)} tools, " - f"excluded {excluded_count} write tools" - ) - return read_only_tools + async def discover_mcp_tools(self) -> list[str]: + """List exact MCP tool identifiers without granting agent access.""" + discovered = await self._load_mcp_tools(discovery=True) + return sorted(name for name, _tool in discovered) async def _create_agent_async( self, @@ -498,12 +462,15 @@ async def _create_agent_async( """ root_dir = self._get_root_dir() skill_paths = self._get_skill_paths(ticket_key) + builtin_tools = parse_host_tools( + self.settings.agent_allowed_tools, enabled=self.settings.agent_enable_tools + ) # Log configuration for visibility logger.info(f"Agent config: root_dir={root_dir}, skills={skill_paths}") # Create filesystem backend - backend = FilesystemBackend(root_dir=str(root_dir)) + backend = FilesystemBackend(root_dir=str(root_dir), virtual_mode=True) # Create the model (supports both direct API and Vertex AI) model = self._create_model(model_target=model_target) @@ -513,8 +480,7 @@ async def _create_agent_async( if not include_tools: logger.info("Agent tools: disabled (include_tools=False)") elif mcp_tools: - mode = "read-only" if self.settings.agent_mcp_read_only else "full access" - logger.info(f"Agent tools: {len(mcp_tools)} MCP tools ({mode})") + logger.info("Agent tools: %d exactly allowlisted MCP tools", len(mcp_tools)) else: logger.info("Agent tools: none (no MCP tools loaded)") @@ -528,6 +494,10 @@ async def _create_agent_async( system_prompt=system_prompt, checkpointer=self._checkpointer, tools=mcp_tools if mcp_tools else None, + middleware=[ + HostToolAllowlistMiddleware(set(builtin_tools) | {tool.name for tool in mcp_tools}) + ], + permissions=[FilesystemPermission(operations=["write"], paths=["/**"], mode="deny")], ) return agent @@ -922,7 +892,7 @@ def _load_mcp_config(self) -> dict[str, Any]: if enabled_setting == "*": # All servers enabled logger.info(f"MCP enabled with all servers: {list(all_servers.keys())}") - return all_servers + return self._sanitize_mcp_subprocesses(all_servers) # Filter to only enabled servers enabled_list = [s.strip() for s in enabled_setting.split(",") if s.strip()] @@ -931,7 +901,18 @@ def _load_mcp_config(self) -> dict[str, Any]: } logger.info(f"MCP enabled with servers: {list(filtered_servers.keys())}") - return filtered_servers + return self._sanitize_mcp_subprocesses(filtered_servers) + + @staticmethod + def _sanitize_mcp_subprocesses(servers: dict[str, Any]) -> dict[str, Any]: + """Prevent local MCP commands from inheriting Forge's process environment.""" + sanitized: dict[str, Any] = {} + for name, original in servers.items(): + config = dict(original) + if config.get("transport") == "stdio" or "command" in config: + config["env"] = operational_subprocess_env(config.get("env", {})) + sanitized[name] = config + return sanitized def _parse_mcp_config(self, config_path: Path) -> dict[str, Any]: """Parse MCP config file and expand environment variables. @@ -1326,7 +1307,7 @@ async def answer_question( "ticket_key": context.get("ticket_key", ""), }, trace_context=_forward_trace_fields(context), - # Q&A gets read-only MCP tools for lookups (filtered by agent_mcp_read_only) + # Q&A gets only the exactly allowlisted MCP tools for lookups. ) logger.info(f"Generated answer ({len(result)} chars)") diff --git a/src/forge/integrations/agents/security.py b/src/forge/integrations/agents/security.py new file mode 100644 index 000000000..40d0dc99c --- /dev/null +++ b/src/forge/integrations/agents/security.py @@ -0,0 +1,151 @@ +"""Security boundaries for host-side Deep Agents.""" + +from __future__ import annotations + +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import Any + +from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse + +SAFE_BUILTIN_TOOLS = frozenset({"ls", "read_file", "glob", "grep"}) +PROHIBITED_BUILTIN_TOOLS = frozenset({"write_file", "edit_file", "execute"}) + + +class HostToolAllowlistMiddleware(AgentMiddleware): + """Expose only explicitly granted tools to the host agent model.""" + + def __init__(self, allowed: set[str] | frozenset[str]) -> None: + self.allowed = frozenset(allowed) + + def _request(self, request: ModelRequest) -> ModelRequest: + return request.override(tools=[tool for tool in request.tools if tool.name in self.allowed]) + + def wrap_model_call(self, request: ModelRequest, handler: Any) -> ModelResponse: + return handler(self._request(request)) + + async def awrap_model_call(self, request: ModelRequest, handler: Any) -> ModelResponse: + return await handler(self._request(request)) + + +def parse_host_tools(value: str, *, enabled: bool = True) -> frozenset[str]: + """Validate the host built-in tool allowlist.""" + if not enabled: + return frozenset() + requested = frozenset(item.strip() for item in value.split(",") if item.strip()) + unknown = requested - SAFE_BUILTIN_TOOLS - PROHIBITED_BUILTIN_TOOLS + prohibited = requested & PROHIBITED_BUILTIN_TOOLS + if value.strip() == "*": + raise ValueError("AGENT_ALLOWED_TOOLS='*' is unsafe for host agents") + if unknown: + raise ValueError(f"Unknown host agent tools: {', '.join(sorted(unknown))}") + if prohibited: + raise ValueError(f"Prohibited host agent tools: {', '.join(sorted(prohibited))}") + return requested + + +def validate_agent_root(root: Path, project_root: Path, workspace_base: str = "") -> Path: + """Create and validate a dedicated agent root that cannot expose Forge data.""" + if root.is_symlink(): + raise ValueError(f"Agent root must not be a symlink: {root}") + root.mkdir(parents=True, exist_ok=True, mode=0o700) + resolved = root.resolve(strict=True) + project = project_root.resolve(strict=True) + home = Path.home().resolve() + protected = [ + project / ".env", + project / ".git", + home / ".ssh", + home / ".aws", + home / ".config" / "gcloud", + home / ".config" / "gh", + ] + if workspace_base: + protected.append(Path(workspace_base).resolve()) + + # A root below the source tree is safe; a root equal to or above it is not. + if resolved == project or resolved in project.parents: + raise ValueError(f"Agent root overlaps Forge source: {resolved}") + for path in protected: + if path == resolved or resolved in path.parents: + raise ValueError(f"Agent root exposes protected path: {path}") + # mkdir's mode is ignored for an existing directory and is filtered by the + # process umask for a new one. Enforce the isolation boundary only after the + # path has passed validation so a rejected path is never chmodded. + try: + resolved.chmod(0o700) + except PermissionError as exc: + # Kubernetes emptyDir volumes are commonly owned by root and made + # writable to the workload through fsGroup. The workload can use the + # directory but cannot chmod it because it is not the owner. + mode = stat.S_IMODE(resolved.stat().st_mode) + accessible = all(os.access(resolved, flag) for flag in (os.R_OK, os.W_OK, os.X_OK)) + if mode & 0o007 or not accessible: + raise ValueError( + f"Agent root permissions are not private and writable: {resolved}" + ) from exc + return resolved + + +def _assert_safe_tree(source: Path) -> Path: + source = source.absolute() + if source.is_symlink() or not source.is_dir(): + raise ValueError(f"Skill source must be a real directory: {source}") + resolved_source = source.resolve(strict=True) + for entry in source.rglob("*"): + if entry.is_symlink(): + raise ValueError(f"Symlinks are not allowed in skills: {entry}") + try: + entry.resolve(strict=True).relative_to(resolved_source) + except ValueError as exc: + raise ValueError(f"Skill path escapes its source: {entry}") from exc + return resolved_source + + +def initialize_agent_skills(agent_root: Path, source_root: Path) -> Path: + """Seed the isolated runtime skill tree from committed skill directories.""" + safe_source = _assert_safe_tree(source_root) + skills_root = agent_root / "committed-skills" + # Keep repository-owned skills separate from runtime-fetched skills. This + # allows an exact rebuild to remove files or whole skills deleted upstream + # without destroying packages installed at runtime under agent_root/skills. + if skills_root.exists(): + if skills_root.is_symlink() or not skills_root.is_dir(): + raise ValueError(f"Committed skill destination must be a real directory: {skills_root}") + shutil.rmtree(skills_root) + skills_root.mkdir(parents=True, exist_ok=True) + for source in sorted(safe_source.iterdir()): + if source.is_dir(): + shutil.copytree(source, skills_root / source.name, dirs_exist_ok=True) + return skills_root + + +def operational_subprocess_env(explicit: dict[str, str] | None = None) -> dict[str, str]: + """Return the non-secret operational environment allowed in child processes.""" + allowed = { + "PATH", + "HOME", + "LANG", + "LANGUAGE", + "LC_ALL", + "LC_CTYPE", + "TMPDIR", + "TMP", + "TEMP", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_USER_NAME", + "GIT_USER_EMAIL", + } + result = {key: value for key, value in os.environ.items() if key in allowed} + result.setdefault("PATH", os.defpath) + result.setdefault("HOME", str(Path.home())) + result.setdefault("LANG", "C.UTF-8") + result.setdefault("TMPDIR", tempfile.gettempdir()) + result.update(explicit or {}) + return result diff --git a/src/forge/main.py b/src/forge/main.py index 3eb082fa7..5fe548496 100644 --- a/src/forge/main.py +++ b/src/forge/main.py @@ -4,6 +4,7 @@ import os from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from pathlib import Path from dotenv import load_dotenv from fastapi import FastAPI @@ -13,6 +14,7 @@ from forge.api.middleware.correlation import CorrelationIdMiddleware from forge.api.routes import github_router, health_router, jira_router, metrics_router from forge.config import get_settings +from forge.integrations.agents.security import initialize_agent_skills, validate_agent_root from forge.observability.config import configure_tracing, shutdown_tracing from forge.orchestrator.checkpointer import close_redis_pool @@ -34,6 +36,13 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: log_startup_banner("API Gateway") + project_root = Path(os.environ.get("FORGE_PROJECT_ROOT", Path.cwd())).resolve() + agent_root = validate_agent_root( + Path(settings.agent_root_dir), project_root, settings.workspace_base_dir or "" + ) + initialize_agent_skills(agent_root, project_root / settings.skills_dir) + logger.info("Host agent root initialized at %s", agent_root) + # Startup - initialize tracing if settings.tracing_enabled: configure_tracing( diff --git a/src/forge/orchestrator/worker.py b/src/forge/orchestrator/worker.py index 74c72682b..767ad752a 100644 --- a/src/forge/orchestrator/worker.py +++ b/src/forge/orchestrator/worker.py @@ -18,6 +18,7 @@ record_workflow_started, ) from forge.config import get_settings +from forge.integrations.agents.security import initialize_agent_skills, validate_agent_root from forge.integrations.github.client import GitHubClient from forge.integrations.github.comment_signature import is_self_comment, resolve_bot_login from forge.integrations.jira.client import JiraClient @@ -2160,6 +2161,17 @@ async def start(self) -> None: log_startup_banner("Queue Worker") + project_root = Path(os.environ.get("FORGE_PROJECT_ROOT", Path.cwd())).resolve() + agent_root = validate_agent_root( + Path(self.settings.agent_root_dir), + project_root, + self.settings.workspace_base_dir or "", + ) + committed_skills = initialize_agent_skills( + agent_root, project_root / self.settings.skills_dir + ) + logger.info("Host agent skills initialized at %s", committed_skills) + # Start Prometheus metrics HTTP server if self.settings.worker_metrics_enabled: from prometheus_client import start_http_server diff --git a/src/forge/sandbox/runner.py b/src/forge/sandbox/runner.py index ffed814d9..344fbe2b4 100644 --- a/src/forge/sandbox/runner.py +++ b/src/forge/sandbox/runner.py @@ -270,23 +270,20 @@ def _get_skill_mounts( ) -> tuple[list[tuple[Path, str]], str]: """Get skill directory mounts and container paths. - Resolves skill directories via the resolver in ascending priority: - committed defaults (``skills_dir/default/``), committed per-project - overrides (``skills_dir/{project}/``), and runtime-fetched project - skills (``skills_install_dir/{project}/``), with later sources winning. + Resolves default and project skill directories from the isolated runtime + skill tree populated by the worker. Returns: Tuple of (mounts, container_paths) where: - mounts: List of (host_path, container_path) tuples - container_paths: Comma-separated paths for AGENT_SKILL_PATHS env var """ - skills_dir = Path.cwd() / self.settings.skills_dir.rstrip("/") - # Assumes worker and runner share the same host filesystem — skills_install_dir - # is populated by the worker and mounted into the container from the host. host_paths = [ Path(p.rstrip("/")) for p in resolve_skill_paths( - ticket_key or "", skills_dir, skills_install_dir=self.settings.skills_install_dir + ticket_key or "", + self.settings.committed_skills_dir, + skills_install_dir=self.settings.skills_install_dir, ) ] diff --git a/src/forge/skills/installer.py b/src/forge/skills/installer.py index 6c45d0fb6..1e0ce0c30 100644 --- a/src/forge/skills/installer.py +++ b/src/forge/skills/installer.py @@ -104,6 +104,9 @@ def install_skill_mapping( installed: list[str] = [] for target_name, source_subdir in mapping.items(): + target_path = Path(target_name) + if target_name in {"", ".", ".."} or target_path.name != target_name: + raise ValueError(f"Unsafe skill target name: {target_name!r}") skill_source = source_dir / source_subdir if not skill_source.exists() or not skill_source.is_dir(): @@ -114,6 +117,11 @@ def install_skill_mapping( ) continue + try: + skill_source.resolve(strict=True).relative_to(source_dir.resolve(strict=True)) + except ValueError as exc: + raise ValueError(f"Skill source escapes cloned repository: {source_subdir!r}") from exc + skill_marker = skill_source / _SKILL_MARKER if not skill_marker.exists(): logger.warning( @@ -139,6 +147,16 @@ def install_skill_mapping( def _copy_dir(src: Path, dest: Path) -> None: """Copy *src* to *dest*, removing *dest* first if it already exists.""" + resolved_source = src.resolve(strict=True) + if src.is_symlink(): + raise ValueError(f"Skill source must not be a symlink: {src}") + for entry in src.rglob("*"): + if entry.is_symlink(): + raise ValueError(f"Symlinks are not allowed in skills: {entry}") + try: + entry.resolve(strict=True).relative_to(resolved_source) + except ValueError as exc: + raise ValueError(f"Skill path escapes its source: {entry}") from exc if dest.exists(): shutil.rmtree(dest) shutil.copytree(src, dest) diff --git a/src/forge/skills/resolver.py b/src/forge/skills/resolver.py index 9fdc95b19..80c730787 100644 --- a/src/forge/skills/resolver.py +++ b/src/forge/skills/resolver.py @@ -1,4 +1,5 @@ import logging +import re from pathlib import Path logger = logging.getLogger(__name__) @@ -27,6 +28,8 @@ def resolve_skill_paths( return [str(default_dir) + "/"] project = ticket_key.split("-")[0].lower() + if not re.fullmatch(r"[a-z0-9_]+", project): + raise ValueError(f"Unsafe project key for skill resolution: {project!r}") paths: list[str] = [str(default_dir) + "/"] override_dir = skills_dir / project diff --git a/tests/unit/integrations/agents/test_agent.py b/tests/unit/integrations/agents/test_agent.py index 066264094..8e91d7834 100644 --- a/tests/unit/integrations/agents/test_agent.py +++ b/tests/unit/integrations/agents/test_agent.py @@ -1,6 +1,7 @@ """Unit tests for ForgeAgent.""" -from unittest.mock import ANY, AsyncMock, MagicMock, patch +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -204,11 +205,14 @@ def test_get_skill_paths_returns_default_without_ticket_key(): """When ticket_key is None, resolver returns skills/default/ only.""" agent = ForgeAgent.__new__(ForgeAgent) agent.settings = MagicMock() - agent.settings.skills_dir = "skills/" + agent.settings.agent_root_dir = ".forge/agent" with patch("forge.integrations.agents.agent.resolve_skill_paths") as mock_resolver: mock_resolver.return_value = ["skills/default/"] result = agent._get_skill_paths(None) - mock_resolver.assert_called_once_with("", ANY, skills_install_dir=ANY) + root = Path(".forge/agent").resolve() + mock_resolver.assert_called_once_with( + "", root / "committed-skills", skills_install_dir=root / "skills" + ) assert result == ["skills/default/"] diff --git a/tests/unit/integrations/agents/test_response_parsing.py b/tests/unit/integrations/agents/test_response_parsing.py index e148e5a6b..89b8a81f6 100644 --- a/tests/unit/integrations/agents/test_response_parsing.py +++ b/tests/unit/integrations/agents/test_response_parsing.py @@ -372,73 +372,3 @@ def test_no_expansion_needed(self): result = agent._expand_env_vars(None) assert result is None - - -class TestFilterReadOnlyTools: - """Test _filter_read_only_tools() for MCP tool filtering.""" - - def test_filter_write_tools(self): - """Filter out tools with write-indicating names.""" - agent = ForgeAgent.__new__(ForgeAgent) - - # Mock tools with name attribute - class MockTool: - def __init__(self, name): - self.name = name - - tools = [ - MockTool("get_issue"), - MockTool("create_issue"), - MockTool("list_files"), - MockTool("update_issue"), - MockTool("search_code"), - MockTool("delete_branch"), - MockTool("add_comment"), - MockTool("read_file"), - MockTool("push_changes"), - ] - - read_only = agent._filter_read_only_tools(tools) - read_only_names = [t.name for t in read_only] - - # Should keep read operations - assert "get_issue" in read_only_names - assert "list_files" in read_only_names - assert "search_code" in read_only_names - assert "read_file" in read_only_names - - # Should filter out write operations - assert "create_issue" not in read_only_names - assert "update_issue" not in read_only_names - assert "delete_branch" not in read_only_names - assert "add_comment" not in read_only_names - assert "push_changes" not in read_only_names - - def test_filter_tools_with_write_suffix(self): - """Filter tools ending with _write suffix.""" - agent = ForgeAgent.__new__(ForgeAgent) - - class MockTool: - def __init__(self, name): - self.name = name - - tools = [ - MockTool("file_read"), - MockTool("file_write"), - MockTool("config_read"), - MockTool("config_write"), - ] - - read_only = agent._filter_read_only_tools(tools) - read_only_names = [t.name for t in read_only] - - assert "file_read" in read_only_names - assert "config_read" in read_only_names - assert "file_write" not in read_only_names - assert "config_write" not in read_only_names - - def test_empty_tools_list(self): - """Handle empty tools list.""" - agent = ForgeAgent.__new__(ForgeAgent) - result = agent._filter_read_only_tools([]) - assert result == [] diff --git a/tests/unit/integrations/agents/test_security.py b/tests/unit/integrations/agents/test_security.py new file mode 100644 index 000000000..b99861566 --- /dev/null +++ b/tests/unit/integrations/agents/test_security.py @@ -0,0 +1,227 @@ +import shutil +import stat +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from forge.integrations.agents.agent import ForgeAgent +from forge.integrations.agents.security import ( + initialize_agent_skills, + operational_subprocess_env, + parse_host_tools, + validate_agent_root, +) +from forge.skills.resolver import resolve_skill_paths + + +def test_host_tools_default_safe_and_write_tools_prohibited() -> None: + assert parse_host_tools("ls,read_file,glob,grep") == {"ls", "read_file", "glob", "grep"} + with pytest.raises(ValueError, match="Prohibited"): + parse_host_tools("ls,execute") + with pytest.raises(ValueError, match="unsafe"): + parse_host_tools("*") + with pytest.raises(ValueError, match="Unknown"): + parse_host_tools("web_search") + + +def test_agent_root_cannot_expose_project_or_workspace(tmp_path: Path) -> None: + project = tmp_path / "forge" + project.mkdir() + assert validate_agent_root(project / ".forge" / "agent", project).is_dir() + with pytest.raises(ValueError, match="Forge source"): + validate_agent_root(tmp_path, project) + + +def test_agent_root_enforces_private_permissions(tmp_path: Path) -> None: + project = tmp_path / "forge" + project.mkdir() + root = tmp_path / "agent" + root.mkdir(mode=0o755) + + validate_agent_root(root, project) + + assert stat.S_IMODE(root.stat().st_mode) == 0o700 + + +def test_agent_root_accepts_private_fs_group_volume(tmp_path: Path) -> None: + """A writable group-owned Kubernetes volume need not be chmod-able by the process.""" + project = tmp_path / "forge" + project.mkdir() + root = tmp_path / "agent" + root.mkdir(mode=0o770) + + original_chmod = Path.chmod + + def deny_agent_root_chmod(path: Path, mode: int, *args, **kwargs) -> None: + if path == root.resolve(): + raise PermissionError("not the volume owner") + original_chmod(path, mode, *args, **kwargs) + + with patch.object(Path, "chmod", deny_agent_root_chmod): + assert validate_agent_root(root, project) == root.resolve() + + +def test_agent_root_rejects_world_accessible_unowned_volume(tmp_path: Path) -> None: + project = tmp_path / "forge" + project.mkdir() + root = tmp_path / "agent" + root.mkdir(mode=0o777) + + with ( + patch.object(Path, "chmod", side_effect=PermissionError("not the volume owner")), + pytest.raises(ValueError, match="not private and writable"), + ): + validate_agent_root(root, project) + + +def test_agent_root_does_not_chmod_rejected_path(tmp_path: Path) -> None: + project = tmp_path / "forge" + project.mkdir(mode=0o755) + original_mode = stat.S_IMODE(project.stat().st_mode) + + with pytest.raises(ValueError, match="Forge source"): + validate_agent_root(project, project) + + assert stat.S_IMODE(project.stat().st_mode) == original_mode + + +def test_agent_root_rejects_symlink(tmp_path: Path) -> None: + project = tmp_path / "forge" + project.mkdir() + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "agent" + link.symlink_to(real, target_is_directory=True) + with pytest.raises(ValueError, match="symlink"): + validate_agent_root(link, project) + + +def test_skill_initialization_rejects_symlinks(tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir() + (source / "SKILL.md").write_text("safe") + (source / "escape").symlink_to(tmp_path / "outside") + root = tmp_path / "agent" + root.mkdir() + with pytest.raises(ValueError, match="Symlinks"): + initialize_agent_skills(root, source) + + +def test_skill_resolution_rejects_path_escape(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="Unsafe project key"): + resolve_skill_paths("../../-1", tmp_path) + + +def test_skill_initialization_preserves_default_and_project_layout(tmp_path: Path) -> None: + source = tmp_path / "skills" + (source / "default" / "common").mkdir(parents=True) + (source / "default" / "common" / "SKILL.md").write_text("default") + (source / "aisos" / "project").mkdir(parents=True) + (source / "aisos" / "project" / "SKILL.md").write_text("project") + root = tmp_path / "agent" + root.mkdir() + + skills_root = initialize_agent_skills(root, source) + + assert (skills_root / "default" / "common" / "SKILL.md").read_text() == "default" + assert (skills_root / "aisos" / "project" / "SKILL.md").read_text() == "project" + + +def test_skill_initialization_prunes_deleted_committed_skills(tmp_path: Path) -> None: + source = tmp_path / "skills" + removed = source / "default" / "removed" + removed.mkdir(parents=True) + (removed / "SKILL.md").write_text("unsafe") + root = tmp_path / "agent" + root.mkdir() + + skills_root = initialize_agent_skills(root, source) + assert (skills_root / "default" / "removed" / "SKILL.md").is_file() + + shutil.rmtree(removed) + initialize_agent_skills(root, source) + + assert not (skills_root / "default" / "removed").exists() + + +def test_skill_initialization_preserves_runtime_installed_skills(tmp_path: Path) -> None: + source = tmp_path / "skills" + (source / "default" / "common").mkdir(parents=True) + (source / "default" / "common" / "SKILL.md").write_text("default") + root = tmp_path / "agent" + runtime_skill = root / "skills" / "aisos" / "runtime" + runtime_skill.mkdir(parents=True) + (runtime_skill / "SKILL.md").write_text("runtime") + + initialize_agent_skills(root, source) + + assert (runtime_skill / "SKILL.md").read_text() == "runtime" + + +def test_operational_env_drops_secrets(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PATH", "/bin") + monkeypatch.setenv("ANTHROPIC_API_KEY", "secret") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret") + env = operational_subprocess_env() + assert env["PATH"] == "/bin" + assert "ANTHROPIC_API_KEY" not in env + assert "LANGFUSE_SECRET_KEY" not in env + + +@pytest.mark.asyncio +async def test_mcp_tools_are_default_deny_and_exact_allowlisted() -> None: + tools = { + "github": [SimpleNamespace(name="get_issue"), SimpleNamespace(name="create_issue")], + "jira": [SimpleNamespace(name="get_issue")], + } + + class Client: + def __init__(self, config): + self.server = next(iter(config)) + + async def get_tools(self): + return tools[self.server] + + agent = ForgeAgent.__new__(ForgeAgent) + agent.settings = SimpleNamespace(agent_mcp_allowed_tools="github:get_issue") + agent._load_mcp_config = lambda: {"github": {}, "jira": {}} + agent._wrap_tool_with_error_handling = lambda tool: tool + with patch("forge.integrations.agents.agent.MultiServerMCPClient", Client): + loaded = await agent._load_mcp_tools() + discovered = await agent.discover_mcp_tools() + + assert [tool.name for tool in loaded] == ["get_issue"] + assert discovered == ["github:create_issue", "github:get_issue", "jira:get_issue"] + + +@pytest.mark.asyncio +async def test_mcp_tools_cannot_reenable_prohibited_builtin_by_name() -> None: + agent = ForgeAgent.__new__(ForgeAgent) + agent.settings = SimpleNamespace(agent_mcp_allowed_tools="local:execute") + agent._load_mcp_config = lambda: {"local": {}} + + with pytest.raises(ValueError, match="collide.*local:execute"): + await agent._load_mcp_tools() + + +@pytest.mark.asyncio +async def test_mcp_tools_cannot_shadow_safe_builtin_by_name() -> None: + agent = ForgeAgent.__new__(ForgeAgent) + agent.settings = SimpleNamespace(agent_mcp_allowed_tools="local:read_file") + agent._load_mcp_config = lambda: {"local": {}} + + with pytest.raises(ValueError, match="collide.*local:read_file"): + await agent._load_mcp_tools() + + +def test_stdio_mcp_environment_is_sanitized(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "secret") + monkeypatch.setenv("PATH", "/bin") + servers = ForgeAgent._sanitize_mcp_subprocesses( + {"local": {"transport": "stdio", "command": "tool", "env": {"TOKEN": "needed"}}} + ) + assert servers["local"]["env"]["PATH"] == "/bin" + assert servers["local"]["env"]["TOKEN"] == "needed" + assert "ANTHROPIC_API_KEY" not in servers["local"]["env"] diff --git a/tests/unit/sandbox/test_runner_skills.py b/tests/unit/sandbox/test_runner_skills.py new file mode 100644 index 000000000..5a42858d8 --- /dev/null +++ b/tests/unit/sandbox/test_runner_skills.py @@ -0,0 +1,31 @@ +"""Tests for mounting isolated skills in agent containers.""" + +from pathlib import Path +from types import SimpleNamespace + +from forge.sandbox.runner import ContainerRunner + + +def test_get_skill_mounts_includes_committed_and_fetched_skills(tmp_path: Path) -> None: + """Container agents receive every skill layer in resolution order.""" + committed = tmp_path / "committed-skills" + installed = tmp_path / "skills" + expected = [committed / "default", committed / "proj", installed / "proj"] + for skill_dir in expected: + skill_dir.mkdir(parents=True) + + runner = object.__new__(ContainerRunner) + runner.settings = SimpleNamespace( + committed_skills_dir=committed, + skills_install_dir=installed, + ) + + mounts, container_paths = runner._get_skill_mounts("PROJ-123") + + assert [host_path for host_path, _ in mounts] == expected + assert [container_path for _, container_path in mounts] == [ + "/skills/skill_0", + "/skills/skill_1", + "/skills/skill_2", + ] + assert container_paths == "/skills/skill_0/,/skills/skill_1/,/skills/skill_2/" diff --git a/tests/unit/skills/test_installer.py b/tests/unit/skills/test_installer.py index 5c9e5e57a..b3d6b8fe6 100644 --- a/tests/unit/skills/test_installer.py +++ b/tests/unit/skills/test_installer.py @@ -156,6 +156,14 @@ def test_logs_debug_when_no_skills_found( assert any("No skill subdirectories" in msg for msg in caplog.messages) + def test_rejects_symlinks_in_skill_tree(self, tmp_path: Path) -> None: + source = tmp_path / "source" + skill = _make_skill(source, "unsafe") + (skill / "escape").symlink_to(tmp_path / "outside") + + with pytest.raises(ValueError, match="Symlinks"): + install_path_mode(source, tmp_path / "target") + # =========================================================================== # install_skill_mapping @@ -272,6 +280,22 @@ def test_empty_mapping_returns_empty_list(self, tmp_path: Path) -> None: assert result == [] + def test_rejects_target_path_traversal(self, tmp_path: Path) -> None: + source = tmp_path / "source" + _make_skill(source, "safe") + + with pytest.raises(ValueError, match="Unsafe skill target"): + install_skill_mapping(source, {"../escape": "safe"}, tmp_path / "target") + + def test_rejects_source_path_escape(self, tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir() + outside = _make_skill(tmp_path, "outside") + assert outside.is_dir() + + with pytest.raises(ValueError, match="escapes cloned repository"): + install_skill_mapping(source, {"escape": "../outside"}, tmp_path / "target") + def test_raises_file_not_found_for_missing_source(self, tmp_path: Path) -> None: """FileNotFoundError is raised when source directory does not exist.""" source = tmp_path / "nonexistent" diff --git a/uv.lock b/uv.lock index 103d2ec4c..301b9de07 100644 --- a/uv.lock +++ b/uv.lock @@ -177,7 +177,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.89.0" +version = "0.122.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -189,9 +189,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/af/862e216dd6c5e9bc02fb374eeaaa19017c51b90ddfa5692668a3811947bd/anthropic-0.89.0.tar.gz", hash = "sha256:f3d75b8ccef4b35f3702639519e461eba437d4bcdfabb69378c65a02ab7bda66", size = 596758, upload-time = "2026-04-03T18:57:01.348Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/23/9987d70b74e3481d5bc5d2021d3e10fd5f60c1f7b54088ea86506d9b7f2b/anthropic-0.122.0.tar.gz", hash = "sha256:ffec56ae96657c8d19fa575ec96f140f380c353a07ab7d61b92eb18ee6536601", size = 1021535, upload-time = "2026-08-13T18:36:00.307Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/ba/9f973f22abb512d5d17428a76e4ecbc8d49b9dd1b5a1152576d48c24dc1d/anthropic-0.89.0-py3-none-any.whl", hash = "sha256:c6d23854af798f2471ca3bc653cca394d392cc272fe803d3da9d63575b8445f0", size = 478847, upload-time = "2026-04-03T18:56:59.54Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/f5c87e71097a9f89f1b414d1ef7ae8439051fae57d5e4ee90946082982b8/anthropic-0.122.0-py3-none-any.whl", hash = "sha256:45ec906452ffae6b5f7f0c53d01f50bfb7e4ce878d7ae8e4309d13171e557e67", size = 1041853, upload-time = "2026-08-13T18:36:01.831Z" }, ] [package.optional-dependencies] @@ -664,18 +664,19 @@ wheels = [ [[package]] name = "deepagents" -version = "0.4.12" +version = "0.6.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, { name = "langchain-anthropic" }, { name = "langchain-core" }, { name = "langchain-google-genai" }, + { name = "langsmith" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/ef/0b2ccd5e4f40c1554145de17a7f3ee41994de1dda3ea36abe28600f1a3cf/deepagents-0.4.12.tar.gz", hash = "sha256:fc24a691e5cba00920ac4fa1d94f8147d6081fe513ed22bdba7da469288681c3", size = 91870, upload-time = "2026-03-20T14:54:29.904Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/db/a6acdc72a9e90c3f07ed10de35c951734a02d4facb693bb59684ad368801/deepagents-0.6.12.tar.gz", hash = "sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f", size = 211364, upload-time = "2026-06-25T17:26:52.775Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/77/63a5cb4e3a8871c4a52d600661a232c26b35f52931ee551c3adc38eeacf6/deepagents-0.4.12-py3-none-any.whl", hash = "sha256:76a272bac25607c5ef8c5adc876e391da945f1107b504686964dfdb6afdc1ebb", size = 104455, upload-time = "2026-03-20T14:54:28.786Z" }, + { url = "https://files.pythonhosted.org/packages/98/49/af7219b3c13520fee047bb807cfaefba17f8e4584c551d946773589a4f08/deepagents-0.6.12-py3-none-any.whl", hash = "sha256:28b8fa0119ca0a689e3e18e288c4634e4046062acfc87a1cb34289d3af3a1c88", size = 236120, upload-time = "2026-06-25T17:26:51.736Z" }, ] [[package]] @@ -849,7 +850,7 @@ k8s = [ [package.metadata] requires-dist = [ { name = "anthropic", extras = ["vertex"], specifier = ">=0.40.0" }, - { name = "deepagents", specifier = ">=0.1.0" }, + { name = "deepagents", specifier = "==0.6.12" }, { name = "factory-boy", marker = "extra == 'dev'", specifier = ">=3.3.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "ghp-import", marker = "extra == 'docs'", specifier = ">=2.1" }, @@ -1629,38 +1630,40 @@ wheels = [ [[package]] name = "langchain" -version = "1.2.15" +version = "1.3.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/3f/888a7099d2bd2917f8b0c3ffc7e347f1e664cf64267820b0b923c4f339fc/langchain-1.2.15.tar.gz", hash = "sha256:1717b6719daefae90b2728314a5e2a117ff916291e2862595b6c3d6fba33d652", size = 574732, upload-time = "2026-04-03T14:26:03.994Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/1c/b84579174a8e82ed79f4c3e0cd5a7f2323facc5ccd4d1b8390e7d175b663/langchain-1.3.15.tar.gz", hash = "sha256:ab4b775b9703f7e37babe0b325dbbaef25573bda60ecf79f7850bc875f252795", size = 665047, upload-time = "2026-08-11T19:10:52.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/e8/a3b8cb0005553f6a876865073c81ef93bd7c5b18381bcb9ba4013af96ebc/langchain-1.2.15-py3-none-any.whl", hash = "sha256:e349db349cb3e9550c4044077cf90a1717691756cc236438404b23500e615874", size = 112714, upload-time = "2026-04-03T14:26:02.557Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/ae721f4d68ff79a17110cabc9cb39b4568b0e3f1fe0a379b926c3f81d175/langchain-1.3.15-py3-none-any.whl", hash = "sha256:c0d2d0d51ed7da249e8ab7487173872059a9dd46fb071d905957485b7334f987", size = 147001, upload-time = "2026-08-11T19:10:50.846Z" }, ] [[package]] name = "langchain-anthropic" -version = "1.4.0" +version = "1.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/c7/259d4d805c6ac90c8695714fc15498a4557bb515eb24f692fd611966e383/langchain_anthropic-1.4.0.tar.gz", hash = "sha256:bbf64e99f9149a34ba67813e9582b2160a0968de9e9f54f7ba8d1658f253c2e5", size = 674360, upload-time = "2026-03-17T18:42:20.751Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/c6/97c439282c13225beb56ba96ed2a5b1cb2c32eacb84c518fe475ce43711d/langchain_anthropic-1.5.6.tar.gz", hash = "sha256:648fdab25573fc9d29543c4b4af1d682b7b6e548d452bb46e67ebc586e195629", size = 720743, upload-time = "2026-08-13T02:30:48.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/c0/77f99373276d4f06c38a887ef6023f101cfc7ba3b2bf9af37064cdbadde5/langchain_anthropic-1.4.0-py3-none-any.whl", hash = "sha256:c84f55722336935f7574d5771598e674f3959fdca0b51de14c9788dbf52761be", size = 48463, upload-time = "2026-03-17T18:42:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/61b288074742179041cb5390430e52429a0fc41f4a94efec20042bda009f/langchain_anthropic-1.5.6-py3-none-any.whl", hash = "sha256:c358ed2ca90ef75254bb73a96b0a8a8ef0efc1deabe529ea06db6ff403001a27", size = 56547, upload-time = "2026-08-13T02:30:47.118Z" }, ] [[package]] name = "langchain-core" -version = "1.2.26" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "httpx" }, { name = "jsonpatch" }, + { name = "langchain-protocol" }, { name = "langsmith" }, { name = "packaging" }, { name = "pydantic" }, @@ -1669,14 +1672,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/b0/30ed29e5820580bc13d70b1f8a212b4fe0609a9737164ed1a90167941ca2/langchain_core-1.2.26.tar.gz", hash = "sha256:ba025ec70e19b56467f46b9109de19d30d169d328a174986b353cb23fd0ff0fe", size = 844795, upload-time = "2026-04-03T23:30:32.567Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/c1/c226367fdf92a173c8b520d0b3583a0aaf4c3fe4952010f680e49d88589d/langchain_core-1.5.5.tar.gz", hash = "sha256:c08d78176113867e9a76acc1007d641cd68fa5682e9e0018980d062b4ae0777e", size = 983166, upload-time = "2026-08-14T18:56:24.364Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/8b/c184205a52b37a4a3166b3567323495701929b1dbfd528e5ba1df62bd404/langchain_core-1.2.26-py3-none-any.whl", hash = "sha256:3d0a3913dff77a930b017a05afe979e4959d27bec0c77ee51f9a100754510509", size = 508298, upload-time = "2026-04-03T23:30:30.253Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/9f29e62e99118865d092acda6143a1ba6ce1893e3dcb72388a75b798e37d/langchain_core-1.5.5-py3-none-any.whl", hash = "sha256:537037fd44ead7c1ffb9621011029ad2e347e051519d17f112d7bf7be12f4365", size = 565884, upload-time = "2026-08-14T18:56:23.051Z" }, ] [[package]] name = "langchain-google-genai" -version = "4.2.1" +version = "4.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filetype" }, @@ -1684,9 +1687,9 @@ dependencies = [ { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/63/e7d148f903cebfef50109da71378f411166f068d66f79b9e16a62dbacf41/langchain_google_genai-4.2.1.tar.gz", hash = "sha256:7f44487a0337535897e3bba9a1d6605d722629e034f757ffa8755af0aa85daa8", size = 278288, upload-time = "2026-02-19T19:29:19.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/ae/8ba8ee41bd20a23dee95cda109632c8b19a53141fbc81d9f87a72f0e975c/langchain_google_genai-4.3.4.tar.gz", hash = "sha256:265655baad05f799fa7b83a030eaca7cee0e32c9ab7de846b80ea7f59c26134e", size = 287396, upload-time = "2026-08-14T18:10:00.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/7e/46c5973bd8b10a5c4c8a77136cf536e658796380a17c740246074901b038/langchain_google_genai-4.2.1-py3-none-any.whl", hash = "sha256:a7735289cf94ca3a684d830e09196aac8f6e75e647e3a0a1c3c9dc534ceb985e", size = 66500, upload-time = "2026-02-19T19:29:18.002Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f8/9fe4a28e319e9d6b20454e85fea9c243bf011dc866f2c3b9a89d64f9c1a1/langchain_google_genai-4.3.4-py3-none-any.whl", hash = "sha256:618fb0da1b9ba9def5569a8b05cb87e1389de41b8731c802958d09499490d2a5", size = 73338, upload-time = "2026-08-14T18:09:58.772Z" }, ] [[package]] @@ -1726,6 +1729,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/2f/15d5e6c1765d8404a9cce38d8c81d7b33fb3392f9db5b992c000dddbd2a3/langchain_mcp_adapters-0.2.2-py3-none-any.whl", hash = "sha256:d08e64954e86281002653071b7430e0377c9a577cb4ac3143abfeb3e24ef8797", size = 23288, upload-time = "2026-03-16T17:13:29.073Z" }, ] +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + [[package]] name = "langchain-tests" version = "1.1.5" @@ -1769,7 +1784,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.1.6" +version = "1.2.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -1779,22 +1794,22 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/e5/d3f72ead3c7f15769d5a9c07e373628f1fbaf6cbe7735694d7085859acf6/langgraph-1.1.6.tar.gz", hash = "sha256:1783f764b08a607e9f288dbcf6da61caeb0dd40b337e5c9fb8b412341fbc0b60", size = 549634, upload-time = "2026-04-03T19:01:32.561Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/e6/b36ecdb3ff4ba9a290708d514bae89ebbe2f554b6abbe4642acf3fddbe51/langgraph-1.1.6-py3-none-any.whl", hash = "sha256:fdbf5f54fa5a5a4c4b09b7b5e537f1b2fa283d2f0f610d3457ddeecb479458b9", size = 169755, upload-time = "2026-04-03T19:01:30.686Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" }, ] [[package]] name = "langgraph-checkpoint" -version = "4.0.1" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "ormsgpack" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/44/a8df45d1e8b4637e29789fa8bae1db022c953cc7ac80093cfc52e923547e/langgraph_checkpoint-4.0.1.tar.gz", hash = "sha256:b433123735df11ade28829e40ce25b9be614930cd50245ff2af60629234befd9", size = 158135, upload-time = "2026-02-27T21:06:16.092Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/e1/089c4c9e0a2fec7f883f82ae8e6a727138d50074cfeb6644bc2d13b1019b/langgraph_checkpoint-4.2.0.tar.gz", hash = "sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7", size = 180239, upload-time = "2026-08-07T20:05:03.756Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/4c/09a4a0c42f5d2fc38d6c4d67884788eff7fd2cfdf367fdf7033de908b4c0/langgraph_checkpoint-4.0.1-py3-none-any.whl", hash = "sha256:e3adcd7a0e0166f3b48b8cf508ce0ea366e7420b5a73aa81289888727769b034", size = 50453, upload-time = "2026-02-27T21:06:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/3b475f09bd57d3a5649792c66353312b4432afd843f301739dfcebd157f0/langgraph_checkpoint-4.2.0-py3-none-any.whl", hash = "sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942", size = 56833, upload-time = "2026-08-07T20:05:02.655Z" }, ] [[package]] @@ -1828,48 +1843,56 @@ wheels = [ [[package]] name = "langgraph-prebuilt" -version = "1.0.9" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph-checkpoint" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/4c/06dac899f4945bedb0c3a1583c19484c2cc894114ea30d9a538dd270086e/langgraph_prebuilt-1.0.9.tar.gz", hash = "sha256:93de7512e9caade4b77ead92428f6215c521fdb71b8ffda8cd55f0ad814e64de", size = 165850, upload-time = "2026-04-03T14:06:37.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/a2/8368ac187b75e7f9d938ca075d34f116683f5cfc48d924029ee79aea147b/langgraph_prebuilt-1.0.9-py3-none-any.whl", hash = "sha256:776c8e3154a5aef5ad0e5bf3f263f2dcaab3983786cc20014b7f955d99d2d1b2", size = 35958, upload-time = "2026-04-03T14:06:36.58Z" }, + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, ] [[package]] name = "langgraph-sdk" -version = "0.3.12" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, { name = "orjson" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/a1/012f0e0f5c9fd26f92bdc9d244756ad673c428230156ef668e6ec7c18cee/langgraph_sdk-0.3.12.tar.gz", hash = "sha256:c9c9ec22b3c0fcd352e2b8f32a815164f69446b8648ca22606329f4ff4c59a71", size = 194932, upload-time = "2026-03-18T22:15:54.592Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/4d/4f796e86b03878ab20d9b30aaed1ad459eda71a5c5b67f7cfe712f3548f2/langgraph_sdk-0.3.12-py3-none-any.whl", hash = "sha256:44323804965d6ec2a07127b3cf08a0428ea6deaeb172c2d478d5cd25540e3327", size = 95834, upload-time = "2026-03-18T22:15:53.545Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, ] [[package]] name = "langsmith" -version = "0.7.25" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "anyio" }, + { name = "distro" }, { name = "httpx" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, { name = "packaging" }, { name = "pydantic" }, { name = "requests" }, { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, { name = "uuid-utils" }, + { name = "websockets" }, { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/d7/21ffae5ccdc3c9b8de283e8f8bf48a92039681df0d39f15133d8ff8965bd/langsmith-0.7.25.tar.gz", hash = "sha256:d17da71f156ca69eafd28ac9627c8e0e93170260ec37cd27cedc83205a067598", size = 1145410, upload-time = "2026-04-03T13:11:42.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/62/a917e87073767db8ca335c3f5a42fb4c5336509b8d5b03e165e46da47e70/langsmith-0.11.0.tar.gz", hash = "sha256:7339f90e6fd9a1a009445b5084a7a0e56a8b6f17305ee5d7e8c5e7582217854f", size = 4805612, upload-time = "2026-08-14T12:56:55.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/13/67889d41baf7dbaf13ffd0b334a0f284e107fad1cc8782a1abb1e56e5eeb/langsmith-0.7.25-py3-none-any.whl", hash = "sha256:55ecc24c547f6c79b5a684ff8685c669eec34e52fcac5d2c0af7d613aef5a632", size = 359417, upload-time = "2026-04-03T13:11:40.729Z" }, + { url = "https://files.pythonhosted.org/packages/c8/aa/18d334f04c12bb9154568747539a1143a93d0c34b894662c065454f396c8/langsmith-0.11.0-py3-none-any.whl", hash = "sha256:e87a3929915936c066b3fa3283ec3f3f0013e2ef7f98a443a7fbe3fab8e784a3", size = 737950, upload-time = "2026-08-14T12:56:53.014Z" }, ] [[package]] @@ -4054,61 +4077,44 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]]