Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub dbb7b47a5f build(deps): bump cryptography from 48.0.1 to 50.0.0
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.1 to 50.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.1...50.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 04:10:14 +00:00
Anurag MewarandGitHub 6719a70611 feat: support API specs and Postman collections as targets (#866) 2026-08-03 21:07:44 -07:00
Ahmed Allam ea6d53f4e9 build: add types-requests to dev deps
The old openai-agents pin pulled types-requests in transitively; 0.19.0 does
not, so mypy lost the requests stubs. Depend on them directly.
2026-08-04 06:14:54 +03:00
Ahmed Allam 3bcf3778f0 fix(core): settle a non-interactive agent's status before its exception unwinds
An exception escaping a non-interactive cycle re-raised before the status
handling, so a dying child stayed 'running' and its parent waited out the
timeout on a completion report the child could no longer send. Set the
terminal status and wake the parent on the way out too.
2026-08-04 06:14:54 +03:00
Ahmed Allam 4a455b1e62 fix(core): recover from hallucinated tool names instead of ending the scan
A tool call for a name Strix does not register raised ModelBehaviorError
from the SDK turn resolver, which nothing retries: the root agent's raise
tore down the whole scan and a sub-agent died before its status was set.
Opt into the SDK's tool_not_found_behavior="return_error_to_model" so the
unknown call comes back as a tool result and the agent self-corrects.

The setting landed in openai-agents 0.19.0, which requires openai>=2.45,
so both pins move.
2026-08-04 06:14:54 +03:00
Ahmed AllamandAhmed Allam 6f70b6f319 fix(tui): drop the shift+enter newline hint from the setup footer 2026-08-04 06:05:46 +03:00
22 changed files with 1362 additions and 98 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ repos:
fastapi,
pytest,
hatchling,
"openai-agents[litellm]==0.14.6",
"openai-agents[litellm]>=0.19.0,<0.20",
]
args: [--install-types, --non-interactive]
+22
View File
@@ -185,6 +185,28 @@ strix --target https://github.com/org/repo
strix --target https://your-app.com
```
### API Testing (OpenAPI / Swagger / Postman)
Point Strix at an API contract and it tests every declared endpoint instead of
having to discover them by crawling. Pair the spec with the live base URL so the
agent knows where to send traffic:
```bash
# OpenAPI / Swagger file (.json / .yaml)
strix --target ./openapi.yaml --target https://api.your-app.com
# Postman collection export
strix --target ./collection.postman_collection.json --target https://api.your-app.com
# Postman collection pulled live by id (no manual export)
export POSTMAN_API_KEY="PMAK-..."
strix --target postman://<collection-uuid>
# ...with a Postman environment to resolve {{baseUrl}} / token variables
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
```
### Advanced Testing Scenarios
```bash
+4
View File
@@ -80,6 +80,10 @@ affecting the agents that do the actual testing.
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
</ParamField>
<ParamField path="POSTMAN_API_KEY" type="string">
Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://<collection-uid>`), and Postman environments (`postman://<collection-uid>?env=<environment-uid>`) to resolve collection variables. Not needed when passing a local collection export file.
</ParamField>
<ParamField path="STRIX_TELEMETRY" default="1" type="string">
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
</ParamField>
+13 -1
View File
@@ -12,11 +12,17 @@ strix (--target <target> | --target-list <path>) [options]
## Options
<ParamField path="--target, -t" type="string">
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`.
Target to test. Accepts URLs, repositories, local directories, domains, IP addresses, API spec files (OpenAPI/Swagger `.json`/`.yaml`, a Postman collection export), or a live Postman collection by id (`postman://<collection-uuid>`). Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`.
When the target is an API spec, Strix copies it into the agent's workspace and authorizes the base URLs it declares (including those resolved from a Postman environment) as in-scope hosts - so the agent reads the contract and tests the full declared surface instead of discovering endpoints by crawling. Pair the spec with the deployed base URL (e.g. `--target ./openapi.yaml --target https://api.example.com`) so the agent has a reachable host to attack.
<Note>
A local directory is mounted into the sandbox live and **writable**, so the agent edits your real files (`.git` excepted). Commit or stash first.
</Note>
<Note>
Fetching a Postman collection by id requires `POSTMAN_API_KEY`. Add `?env=<environment-uuid>` to also pull a Postman environment, which resolves `{{baseUrl}}` / token variables the collection references (e.g. `postman://<collection-uuid>?env=<environment-uid>`).
</Note>
</ParamField>
<ParamField path="--target-list" type="string">
@@ -128,6 +134,12 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
# Multi-target white-box testing
strix -t https://github.com/org/app -t https://staging.example.com
# API spec + live target (OpenAPI/Swagger file or Postman collection)
strix -t ./openapi.yaml -t https://api.example.com
# Postman collection pulled live by id (+ optional environment)
strix -t "postman://<collection-uuid>?env=<environment-uuid>"
# Targets from a file
strix --target-list ./targets.txt
```
+7 -3
View File
@@ -33,8 +33,8 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"openai-agents[litellm]==0.14.6",
"openai>=2.26.0,<2.45",
"openai-agents[litellm]>=0.19.0,<0.20",
"openai>=2.45.0,<3",
"litellm",
"pydantic>=2.11.3",
"pydantic-settings>=2.13.0",
@@ -47,7 +47,8 @@ dependencies = [
"pypdf>=5.0",
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
"cryptography>=48.0.1,<49",
"cryptography>=48.0.1,<51",
"pyyaml>=6.0",
]
[project.optional-dependencies]
@@ -67,6 +68,7 @@ dev = [
"pyinstaller>=6.17.0; python_version >= '3.12' and python_version < '3.15'",
"pytest>=8.3",
"pytest-asyncio>=0.24",
"types-requests>=2.32",
]
[tool.pytest.ini_options]
@@ -132,6 +134,7 @@ module = [
"pydantic_settings.*",
"reportlab.*",
"pypdf.*",
"yaml.*",
"pygments.*",
]
ignore_missing_imports = true
@@ -233,6 +236,7 @@ ignore = [
"strix/interface/auth_cli.py" = ["N802"]
"tests/test_codex_streaming.py" = ["N802"]
"tests/test_disable_streaming.py" = ["N802"]
"tests/test_unknown_tool_recovery.py" = ["N802"]
"tests/test_report_pdf.py" = ["S105", "S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
+5
View File
@@ -122,6 +122,11 @@ class IntegrationSettings(BaseSettings):
alias="PERPLEXITY_API_KEY",
repr=False,
)
postman_api_key: str | None = Field(
default=None,
alias="POSTMAN_API_KEY",
repr=False,
)
class ViewerSettings(BaseSettings):
+7 -3
View File
@@ -780,17 +780,21 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
await coordinator.set_status(agent_id, "failed", error=str(exc))
await notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
if not interactive:
raise
if isinstance(exc, MaxTurnsExceeded):
status: Status = "stopped"
elif isinstance(exc, UserError | AgentsException | APIError):
status = "failed"
else:
status = "crashed"
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
logger.exception("agent run failed for %s; marking %s", agent_id, status)
# Settle the status and wake the parent before the exception unwinds a
# non-interactive agent's task: a child that dies still owes its parent a
# report, and the parent would otherwise wait out its timeout on a message
# the dead child can no longer send.
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
await notify_parent_on_terminal(coordinator, agent_id, status)
if not interactive:
raise
return None
else:
return cast("RunResultBase | None", stream)
+57 -15
View File
@@ -33,6 +33,50 @@ def _accepts_required_tool_choice(model_name: str | None) -> bool:
return name.startswith("openai/") or is_known_openai_bare_model(name)
def _render_diff_scope(diff_scope: dict[str, Any]) -> list[str]:
"""Render pull-request diff-scope constraints as root-task lines."""
if not diff_scope.get("active"):
return []
parts: list[str] = [
"\n\nScope Constraints:",
"- Pull request diff-scope mode is active. Prioritize changed files "
"and use other files only for context.",
]
for repo_scope in diff_scope.get("repos", []) or []:
label = repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository"
changed = repo_scope.get("analyzable_files_count", 0)
deleted = repo_scope.get("deleted_files_count", 0)
parts.append(f"- {label}: {changed} changed file(s) in primary scope")
if deleted:
parts.append(f"- {label}: {deleted} deleted file(s) are context-only")
return parts
def _render_api_spec(details: dict[str, Any]) -> list[str]:
"""Render an API spec target as root-task lines.
The spec itself is in the workspace, so the task points at the file and lets
the agent read the contract rather than restating a parsed summary of it.
"""
title = details.get("spec_title") or details.get("target_spec", "API")
workspace_path = details.get("workspace_path", "")
lines = [
f"- {title} ({details.get('spec_format', 'api')} specification"
+ (f", available at: {workspace_path}" if workspace_path else "")
+ ")"
]
if base_urls := details.get("base_urls") or []:
lines.append(" - Base URL(s): " + ", ".join(base_urls))
lines.append(
" - Read the specification and test every operation it declares, using "
"its declared parameters, request bodies, and auth. Endpoints in the "
"specification are in scope even when nothing links to them. Load the "
"`api_spec_testing` skill for the methodology, or spawn a specialist "
"with it."
)
return lines
def build_root_task(scan_config: dict[str, Any]) -> str:
targets = scan_config.get("targets", []) or []
diff_scope = scan_config.get("diff_scope") or {}
@@ -43,6 +87,7 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
"Local Codebases": [],
"URLs": [],
"IP Addresses": [],
"API Specifications": [],
}
for target in targets:
@@ -68,6 +113,8 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
sections["URLs"].append(f"- {details.get('target_url', '')}")
elif ttype == "ip_address":
sections["IP Addresses"].append(f"- {details.get('target_ip', '')}")
elif ttype == "api_spec":
sections["API Specifications"].extend(_render_api_spec(details))
parts: list[str] = []
for label, items in sections.items():
@@ -92,21 +139,7 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
"truth for what to do."
)
if diff_scope.get("active"):
parts.append("\n\nScope Constraints:")
parts.append(
"- Pull request diff-scope mode is active. Prioritize changed files "
"and use other files only for context.",
)
for repo_scope in diff_scope.get("repos", []) or []:
label = (
repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository"
)
changed = repo_scope.get("analyzable_files_count", 0)
deleted = repo_scope.get("deleted_files_count", 0)
parts.append(f"- {label}: {changed} changed file(s) in primary scope")
if deleted:
parts.append(f"- {label}: {deleted} deleted file(s) are context-only")
parts.extend(_render_diff_scope(diff_scope))
task = " ".join(parts)
if user_instructions:
@@ -121,6 +154,7 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
"local_code": "target_path",
"web_application": "target_url",
"ip_address": "target_ip",
"api_spec": "target_spec",
}
for target in scan_config.get("targets", []) or []:
ttype = target.get("type", "unknown")
@@ -134,6 +168,14 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
{"type": ttype, "value": value, "workspace_path": workspace_path},
)
# An API spec authorizes the hosts it declares as in-scope web targets
# so the agent can exercise every endpoint without expanding scope.
if ttype == "api_spec":
authorized.extend(
{"type": "web_application", "value": base_url, "workspace_path": ""}
for base_url in details.get("base_urls") or []
)
return {
"scope_source": "system_scan_config",
"authorization_source": "strix_platform_verified_targets",
+3
View File
@@ -268,6 +268,9 @@ async def run_strix_scan(
model_settings=model_settings,
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
trace_include_sensitive_data=False,
# A hallucinated tool name is a recoverable model mistake, not a scan-ending
# error: hand it back as a tool result so the agent can correct itself.
tool_not_found_behavior="return_error_to_model",
)
hooks = ReportUsageHooks(
model=resolved_model,
+12 -2
View File
@@ -65,6 +65,14 @@ Examples:
# Local code analysis
strix --target ./my-project
# API spec test (OpenAPI/Swagger file or Postman collection export)
strix --target ./openapi.yaml --target https://api.example.com
strix --target ./collection.postman_collection.json
# Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment
strix --target postman://<collection-uuid> --target https://api.example.com
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
# Domain penetration test
strix --target example.com
@@ -107,8 +115,10 @@ Examples:
"--target",
type=str,
action="append",
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"Local directories are mounted into the sandbox writable. "
help="Target to test: URL, repository, local directory path, domain name, IP address, "
"an API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection export), or a "
"Postman collection by id (postman://<collection-uuid>[?env=<environment-uuid>], needs "
"POSTMAN_API_KEY). Local directories are mounted into the sandbox writable. "
"Can be specified multiple times for multi-target scans. "
"Fresh runs require --target or --target-list.",
)
+43 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from strix.config import Settings, codex, load_settings
from strix.core.paths import run_dir_for
@@ -28,8 +28,18 @@ from strix.interface.utils import (
read_target_list_file,
resolve_diff_scope_context,
rewrite_localhost_targets,
stage_api_specs,
write_fetched_collection,
)
from strix.telemetry import posthog, scarf
from strix.utils.api_spec import (
SpecParseError,
fetch_postman_collection,
fetch_postman_environment,
load_spec,
spec_base_urls,
spec_title,
)
if TYPE_CHECKING:
@@ -109,6 +119,9 @@ def build_targets_info(args: argparse.Namespace) -> None:
else:
display_target = target
if target_type == "api_spec":
_resolve_api_spec(target, target_dict)
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
@@ -119,6 +132,34 @@ def build_targets_info(args: argparse.Namespace) -> None:
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
def _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
"""Read the spec up front so bad input fails before the run starts.
Records the declared base URLs (the only thing scope authorization can take
from a spec) and, for a ``postman://`` target, downloads the collection to a
local file so the sandbox never needs the Postman API key.
"""
try:
if details.get("source") == "postman_api":
collection_uid = str(details["collection_uid"])
api_key = load_settings().integrations.postman_api_key or ""
raw = fetch_postman_collection(collection_uid, api_key)
environment_uid = str(details.get("environment_uid") or "")
extra_variables = (
fetch_postman_environment(environment_uid, api_key) if environment_uid else None
)
details["target_spec"] = write_fetched_collection(raw, collection_uid)
else:
raw = load_spec(str(details["target_spec"]))
extra_variables = None
base_urls = spec_base_urls(raw, extra_variables=extra_variables)
except SpecParseError as exc:
raise ValueError(f"Invalid API spec '{target}': {exc}") from None
details["spec_title"] = spec_title(raw)
details["base_urls"] = base_urls
def prepare_run(args: argparse.Namespace) -> None:
"""Resolve the run name, clone repos, compute diff-scope, and persist state.
@@ -139,6 +180,7 @@ def prepare_run(args: argparse.Namespace) -> None:
target_info["details"]["cloned_repo_path"] = cloned_path
args.local_sources = collect_local_sources(args.targets_info)
args.local_sources.extend(stage_api_specs(args.targets_info, args.run_name))
diff_scope = resolve_diff_scope_context(
local_sources=args.local_sources,
scope_mode=args.scope_mode,
+1 -2
View File
@@ -496,8 +496,7 @@ func (m Model) setupHintsView(width int) string {
key := lipgloss.NewStyle().Foreground(white).Render
label := render.Dim().Render
hint := func(k, text string) string { return key(k) + label(" "+text) }
left := hint("enter", "launch scan") + label(" ") + hint("shift+enter", "newline") +
label(" ") + hint("ctrl+c", "quit")
left := hint("enter", "launch scan") + label(" ") + hint("ctrl+c", "quit")
if lipgloss.Width(left) > inner {
left = hint("enter", "launch scan")
}
+94 -1
View File
@@ -11,7 +11,7 @@ import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.parse import parse_qs, urlparse
import docker
import requests
@@ -21,6 +21,7 @@ from rich.panel import Panel
from rich.text import Text
from strix.config import load_settings
from strix.utils.api_spec import detect_spec_format
logger = logging.getLogger(__name__)
@@ -484,6 +485,15 @@ def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None)
if target_type == "ip_address":
return str(details.get("target_ip", original) or original)
if target_type == "api_spec":
if details.get("source") == "postman_api":
return "postman-collection"
spec_path = details.get("target_spec", original)
try:
return str(Path(spec_path).stem or spec_path)
except Exception:
return str(spec_path)
return str(original or "pentest")
@@ -1113,6 +1123,24 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
return "repository", {"target_repo": target}
parsed = urlparse(target)
if parsed.scheme == "postman":
collection_uid = f"{parsed.netloc}{parsed.path}".strip("/")
if not collection_uid:
raise ValueError(
f"Missing Postman collection id in '{target}' (expected postman://<collection-uid>)"
)
details = {
"target_spec": target,
"spec_format": "postman",
"source": "postman_api",
"collection_uid": collection_uid,
}
query = parse_qs(parsed.query)
env_uid = (query.get("env") or query.get("environment") or [""])[0].strip()
if env_uid:
details["environment_uid"] = env_uid
return "api_spec", details
if parsed.scheme in ("http", "https"):
if parsed.username or parsed.password:
return "repository", {"target_repo": target}
@@ -1138,6 +1166,12 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
if path.is_dir():
check_mountable_dir(path)
return "local_code", {"target_path": str(path.resolve())}
spec_format = detect_spec_format(path)
if spec_format is not None:
return "api_spec", {
"target_spec": str(path.resolve()),
"spec_format": spec_format,
}
raise ValueError(f"Path exists but is not a directory: {target}")
except (OSError, RuntimeError) as e:
raise ValueError(f"Invalid path: {target} - {e!s}") from e
@@ -1164,6 +1198,9 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
"- A valid URL (http:// or https://)\n"
"- A Git repository URL (https://host/org/repo or git@host:org/repo.git)\n"
"- A local directory path\n"
"- An API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection)\n"
"- A Postman collection by id (postman://<collection-uid>[?env=<environment-uid>], "
"needs POSTMAN_API_KEY)\n"
"- A domain name (e.g., example.com)\n"
"- An IP address (e.g., 192.168.1.10)"
)
@@ -1438,6 +1475,62 @@ def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway:
details["target_ip"] = host_gateway
#: API spec targets are copied into one workspace directory rather than mounted
#: from wherever they happen to live on the host.
API_SPEC_WORKSPACE_SUBDIR = "api-specs"
def write_fetched_collection(collection: dict[str, Any], collection_uid: str) -> str:
"""Write a collection fetched from the Postman API to a local file.
Returns the file path, so a ``postman://`` target continues as an ordinary
spec file from here on and the API key never leaves the host.
"""
staging = Path(tempfile.gettempdir()) / "strix_api_specs" / "fetched"
staging.mkdir(parents=True, exist_ok=True)
path = staging / f"{sanitize_name(collection_uid)}.postman_collection.json"
path.write_text(json.dumps(collection, indent=2), encoding="utf-8")
return str(path)
def stage_api_specs(targets_info: list[dict[str, Any]], run_name: str) -> list[dict[str, Any]]:
"""Copy every ``api_spec`` target into one directory for the sandbox.
A spec is a single file the agent reads, not a tree it works in, so it is
copied to a per-run staging directory that is exposed at
``/workspace/api-specs`` instead of mounting its host location. Each target's
``workspace_path`` records where the agent will find it.
"""
specs = [t for t in targets_info if t.get("type") == "api_spec"]
if not specs:
return []
staging = Path(tempfile.gettempdir()) / "strix_api_specs" / run_name
staging.mkdir(parents=True, exist_ok=True)
used: set[str] = set()
for target in specs:
details = target["details"]
source = Path(str(details["target_spec"]))
name = source.name
stem, suffix = source.stem, source.suffix
count = 1
while name in used:
count += 1
name = f"{stem}-{count}{suffix}"
used.add(name)
shutil.copy2(source, staging / name)
details["workspace_path"] = f"/workspace/{API_SPEC_WORKSPACE_SUBDIR}/{name}"
return [
{
"source_path": str(staging),
"workspace_subdir": API_SPEC_WORKSPACE_SUBDIR,
"protect_metadata": False,
}
]
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
console = Console()
+61
View File
@@ -0,0 +1,61 @@
---
name: api_spec_testing
description: Spec-driven API pentesting — systematically exercise every endpoint from an ingested OpenAPI/Swagger/Postman inventory for authz, injection, and business-logic flaws
---
# API Spec Testing
When a target is an API specification (OpenAPI 3.x, Swagger 2.0, or a Postman
collection), the root task lists it under **API Specifications** with the path
to the spec file in the workspace and the authorized base URL(s). Read the spec
file first and build your own endpoint inventory from it — every operation with
its method, path, parameters, request-body schema (resolve `$ref`/`allOf`), and
auth scheme. Do not rediscover the surface by crawling. Walk the inventory
operation-by-operation and prove findings against the live base URL(s), which
are authorized in scope.
## Methodology
**1. Baseline the contract.** For each endpoint, send a well-formed request that
matches the declared schema and record the normal response (status, shape,
auth requirement). This baseline is what every abuse case is compared against.
**2. Enumerate coverage.** Track every `METHOD path` in the inventory and mark it
tested. Undocumented-but-implied siblings are worth probing too (e.g. if
`GET /users/{id}` exists, try `PUT`/`DELETE`/`PATCH` on the same path even when
the spec omits them — specs routinely under-document write operations).
**3. Prioritize by risk.** Object-scoped reads/writes, exports, admin/staff
operations, and anything touching billing, auth, or PII first.
## What to test per endpoint
Test the full range of API weaknesses against each operation, driven by what the
contract reveals — do not treat the following as an exhaustive checklist. The
highest-yield classes on APIs are **authorization** flaws, since the spec hands
you the object identifiers and privilege boundaries to abuse: examples include
BOLA/IDOR (swap `{id}`/`accountId`/`tenantId` across two accounts), BFLA
(privileged operations with a lower-privilege token), and missing/broken auth
(replay with the token stripped or expired against endpoints whose declared auth
says one is required). Beyond authorization, use the declared parameters and
body schema as a launch point for mass assignment and excessive data exposure,
injection and type-confusion on every parameter, and multi-step business-logic
and rate-limit abuse — and follow the contract wherever it suggests something
else worth probing.
## Validation
A finding is only real once reproduced against the live base URL with a
concrete request/response pair. Capture the exact HTTP request (method, path,
headers, body) and the response proving impact (another account's data, a
privileged action succeeding, an injected payload executing). Prefer two-account
diffs for authorization findings: same request, different token, unauthorized
success.
## Tips
- The base URL(s) from the spec are authorized targets — send real traffic.
- Path templates use `{param}`; substitute real values from your baseline.
- For Postman collections, saved example values and environment variables are
strong hints for valid inputs — use them to get past validation quickly.
- Keep a running coverage table so no operation in the inventory is skipped.
+312
View File
@@ -0,0 +1,312 @@
"""Recognize API specifications and extract the hosts they declare.
Supports OpenAPI 3.x, Swagger 2.0, and Postman Collection v2.1. Two things about
an API spec must be decided on the host, in code: whether a target file is a
spec at all (detection), and which base URLs it authorizes as in-scope hosts
(scope cannot be self-granted by the agent). Everything else about the contract
— operations, parameters, request bodies, auth — is left to the agent, which
reads the spec file directly in the sandbox, so ``$ref``, ``allOf``, and nested
schemas resolve properly instead of being re-parsed here. Collections held only
in Postman are fetched here too, so the API key stays on the host and never
enters the sandbox.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import requests
import yaml
logger = logging.getLogger(__name__)
SPEC_EXTENSIONS = frozenset({".json", ".yaml", ".yml"})
#: Guard against pathological Postman folder nesting.
_MAX_POSTMAN_DEPTH = 25
class SpecParseError(ValueError):
"""Raised when a spec cannot be read, recognized, or fetched."""
def load_spec(path: str | Path) -> dict[str, Any]:
"""Load an API spec file as a mapping.
Raises :class:`SpecParseError` if the file cannot be read or is not a
JSON/YAML mapping.
"""
p = Path(path)
try:
text = p.read_text(encoding="utf-8")
except OSError as exc:
raise SpecParseError(f"Cannot read spec {p}: {exc}") from exc
# JSON is a subset of YAML, so safe_load parses both; try JSON first for a
# clearer error and to keep the fast path fast.
try:
data: Any = json.loads(text)
except json.JSONDecodeError:
try:
data = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise SpecParseError(f"{p} is not valid JSON or YAML: {exc}") from exc
if not isinstance(data, dict):
raise SpecParseError(f"{p} does not contain a mapping at the top level")
return data
def classify_spec(raw: dict[str, Any]) -> str | None:
"""Return ``openapi`` / ``swagger`` / ``postman``, or ``None`` if unrecognized."""
if isinstance(raw.get("openapi"), str):
return "openapi"
if str(raw.get("swagger", "")).startswith("2"):
return "swagger"
info = raw.get("info")
if isinstance(info, dict) and ("_postman_id" in info or "item" in raw):
return "postman"
return None
def detect_spec_format(path: Path) -> str | None:
"""Return the spec format of *path*, or ``None`` if it is not a spec.
Only files whose extension is in :data:`SPEC_EXTENSIONS` are inspected; the
contents are then loaded to confirm, so an arbitrary ``.json`` config is not
mistaken for a spec.
"""
if path.suffix.lower() not in SPEC_EXTENSIONS:
return None
try:
raw = load_spec(path)
except SpecParseError:
return None
return classify_spec(raw)
def spec_title(raw: dict[str, Any]) -> str:
"""Return the spec's declared name, for display in the task and run record."""
info = raw.get("info")
if not isinstance(info, dict):
return "API"
name = info.get("title") or info.get("name") or "API"
return str(name).strip() or "API"
def _absolute_urls(candidates: list[str]) -> list[str]:
"""Keep absolute http(s) URLs, without trailing slashes, in declared order."""
urls: list[str] = []
for candidate in candidates:
split = urlsplit(candidate.strip())
if split.scheme in ("http", "https") and split.netloc:
urls.append(candidate.strip().rstrip("/"))
return list(dict.fromkeys(urls))
_SERVER_VAR_PATTERN = re.compile(r"\{([^{}/]+)\}")
def _resolve_server_url(url: str, variables: Any) -> str:
"""Substitute an OpenAPI server template's variables with their defaults."""
if "{" not in url or not isinstance(variables, dict):
return url
defaults: dict[str, str] = {}
for name, spec in variables.items():
if isinstance(spec, dict) and spec.get("default") is not None:
defaults[str(name)] = str(spec["default"])
return _SERVER_VAR_PATTERN.sub(lambda m: defaults.get(m.group(1), m.group(0)), url)
def _openapi_base_urls(raw: dict[str, Any]) -> list[str]:
servers = raw.get("servers")
if not isinstance(servers, list):
return []
return _absolute_urls(
[
_resolve_server_url(str(server["url"]), server.get("variables"))
for server in servers
if isinstance(server, dict) and server.get("url")
],
)
def _swagger_base_urls(raw: dict[str, Any]) -> list[str]:
host = str(raw.get("host", "")).strip()
if not host:
return []
base_path = str(raw.get("basePath", "")).strip()
schemes = [s for s in (raw.get("schemes") or ["https"]) if isinstance(s, str)]
return _absolute_urls([f"{scheme}://{host}{base_path}" for scheme in schemes])
_POSTMAN_VAR_PATTERN = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
def postman_variables(raw: dict[str, Any]) -> dict[str, str]:
"""Build a ``{name: value}`` map from a Postman ``variable`` block."""
variables: dict[str, str] = {}
entries = raw.get("variable")
if isinstance(entries, list):
for entry in entries:
if isinstance(entry, dict) and entry.get("key") is not None:
variables[str(entry["key"])] = str(entry.get("value", ""))
return variables
def _resolve_postman_vars(text: str, variables: dict[str, str]) -> str:
if not variables or "{{" not in text:
return text
return _POSTMAN_VAR_PATTERN.sub(lambda m: variables.get(m.group(1), m.group(0)), text)
def _postman_request_url(url: Any, variables: dict[str, str]) -> str:
if isinstance(url, str):
raw = url
elif isinstance(url, dict):
raw = str(url.get("raw", ""))
if not raw:
host = url.get("host")
raw = ".".join(str(h) for h in host) if isinstance(host, list) else str(host or "")
else:
return ""
return _resolve_postman_vars(raw, variables)
def _walk_postman_hosts(
items: Any,
variables: dict[str, str],
hosts: list[str],
depth: int = 0,
) -> None:
if depth > _MAX_POSTMAN_DEPTH or not isinstance(items, list):
return
for node in items:
if not isinstance(node, dict):
continue
if isinstance(node.get("item"), list):
_walk_postman_hosts(node["item"], variables, hosts, depth + 1)
continue
request = node.get("request")
if not isinstance(request, dict):
continue
url = _postman_request_url(request.get("url"), variables)
split = urlsplit(url)
if split.scheme and split.netloc:
hosts.append(f"{split.scheme}://{split.netloc}")
def _postman_base_urls(raw: dict[str, Any], extra_variables: dict[str, str] | None) -> list[str]:
variables = postman_variables(raw)
if extra_variables:
variables.update(extra_variables) # environment values override collection defaults
hosts: list[str] = []
_walk_postman_hosts(raw.get("item"), variables, hosts)
return _absolute_urls(sorted(set(hosts)))
def spec_base_urls(
raw: dict[str, Any],
*,
extra_variables: dict[str, str] | None = None,
) -> list[str]:
"""Return the absolute base URLs a spec declares, for scope authorization.
Relative and unresolved-template URLs are dropped: an unusable value would
otherwise be authorized as an in-scope host. Callers pair the spec with an
explicit ``--target`` host when the spec declares none.
"""
spec_format = classify_spec(raw)
if spec_format == "openapi":
return _openapi_base_urls(raw)
if spec_format == "swagger":
return _swagger_base_urls(raw)
if spec_format == "postman":
return _postman_base_urls(raw, extra_variables)
raise SpecParseError("File is not a recognized OpenAPI, Swagger, or Postman spec")
POSTMAN_API_BASE = "https://api.getpostman.com"
_POSTMAN_FETCH_TIMEOUT = 30
def _postman_api_json(url: str, api_key: str, label: str) -> dict[str, Any]:
"""GET a Postman API resource and return the parsed JSON payload.
Raises :class:`SpecParseError` with an actionable message on auth, network,
or shape errors.
"""
if not api_key:
raise SpecParseError(
"POSTMAN_API_KEY is not set. Export a Postman API key (PMAK-…) to "
"fetch from the Postman API, or pass a local collection file instead.",
)
try:
response = requests.get(
url,
headers={"X-Api-Key": api_key, "Accept": "application/json"},
timeout=_POSTMAN_FETCH_TIMEOUT,
)
except requests.RequestException as exc:
raise SpecParseError(f"Failed to reach the Postman API: {exc}") from exc
if response.status_code == 401:
raise SpecParseError("Postman API rejected the key (401). Check POSTMAN_API_KEY.")
if response.status_code == 404:
raise SpecParseError(
f"Postman {label} not found (404). Check the id and that the key can access it.",
)
if response.status_code != 200:
raise SpecParseError(f"Postman API returned HTTP {response.status_code} for {label}.")
try:
payload = response.json()
except ValueError as exc:
raise SpecParseError(f"Postman API returned non-JSON for {label}") from exc
if not isinstance(payload, dict):
raise SpecParseError(f"Unexpected Postman API response shape for {label}")
return payload
def fetch_postman_collection(collection_uid: str, api_key: str) -> dict[str, Any]:
"""Fetch a collection from the Postman API and return the raw collection dict.
Uses ``GET /collections/{uid}`` with the ``X-Api-Key`` header. The endpoint
wraps the collection under a ``collection`` key, unwrapped here so the result
matches an exported collection file.
"""
payload = _postman_api_json(
f"{POSTMAN_API_BASE}/collections/{collection_uid}",
api_key,
f"collection {collection_uid}",
)
collection = payload.get("collection", payload)
if not isinstance(collection, dict) or not collection:
raise SpecParseError(f"Postman collection {collection_uid} came back empty")
return collection
def fetch_postman_environment(environment_uid: str, api_key: str) -> dict[str, str]:
"""Fetch a Postman environment and return its enabled ``{key: value}`` pairs.
Disabled values are skipped, matching how Postman resolves an environment at
request time.
"""
payload = _postman_api_json(
f"{POSTMAN_API_BASE}/environments/{environment_uid}",
api_key,
f"environment {environment_uid}",
)
environment = payload.get("environment", payload)
values = environment.get("values") if isinstance(environment, dict) else None
if not isinstance(values, list):
return {}
return {
str(value["key"]): str(value.get("value", ""))
for value in values
if isinstance(value, dict) and value.get("key") and value.get("enabled", True)
}
+290
View File
@@ -0,0 +1,290 @@
"""Tests for spec recognition and base-URL extraction in strix.utils.api_spec."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
import pytest
import requests
import yaml
from strix.utils.api_spec import (
SpecParseError,
classify_spec,
detect_spec_format,
fetch_postman_collection,
fetch_postman_environment,
load_spec,
spec_base_urls,
spec_title,
)
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
OPENAPI_YAML = """
openapi: 3.0.1
info:
title: Shop API
version: 1.0.0
servers:
- url: https://{region}.api.shop.test/{ver}
variables:
region:
default: eu
ver:
default: v1
paths:
/users/{id}:
get:
summary: Get user
"""
SWAGGER_JSON = {
"swagger": "2.0",
"info": {"title": "Legacy"},
"host": "legacy.test",
"basePath": "/api",
"schemes": ["https"],
"paths": {"/orders": {"post": {"summary": "Create order"}}},
}
POSTMAN_JSON = {
"info": {"_postman_id": "abc-123", "name": "Pet Store"},
"item": [
{
"name": "Pets",
"item": [
{
"name": "List pets",
"request": {"method": "GET", "url": {"raw": "https://petstore.test/pets"}},
}
],
},
{
"name": "Add pet",
"request": {"method": "POST", "url": "https://petstore.test/pets"},
},
],
}
def _write(tmp_path: Path, name: str, content: str) -> Path:
path = tmp_path / name
path.write_text(content, encoding="utf-8")
return path
# --- detection -----------------------------------------------------------
def test_detect_openapi_yaml(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "openapi.yaml", OPENAPI_YAML)) == "openapi"
def test_detect_swagger_json(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "swagger.json", json.dumps(SWAGGER_JSON))) == (
"swagger"
)
def test_detect_postman_json(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "collection.json", json.dumps(POSTMAN_JSON))) == (
"postman"
)
def test_detect_ignores_non_spec_extension(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "notes.txt", OPENAPI_YAML)) is None
def test_detect_ignores_non_spec_json(tmp_path: Path) -> None:
assert detect_spec_format(_write(tmp_path, "config.json", json.dumps({"foo": "bar"}))) is None
def test_classify_unrecognized_is_none() -> None:
assert classify_spec({"foo": 1}) is None
# --- loading -------------------------------------------------------------
def test_load_spec_rejects_missing_file(tmp_path: Path) -> None:
with pytest.raises(SpecParseError, match="Cannot read"):
load_spec(tmp_path / "nope.yaml")
def test_load_spec_rejects_malformed_yaml(tmp_path: Path) -> None:
with pytest.raises(SpecParseError):
load_spec(_write(tmp_path, "broken.yaml", "openapi: 3.0.0\npaths: [unclosed"))
def test_load_spec_rejects_non_mapping(tmp_path: Path) -> None:
with pytest.raises(SpecParseError, match="mapping"):
load_spec(_write(tmp_path, "list.json", json.dumps([1, 2, 3])))
def test_spec_title_reads_openapi_and_postman() -> None:
assert spec_title(yaml.safe_load(OPENAPI_YAML)) == "Shop API"
assert spec_title(POSTMAN_JSON) == "Pet Store"
assert spec_title({"info": {}}) == "API"
# --- base URL extraction -------------------------------------------------
def test_openapi_base_urls_resolve_server_variables() -> None:
raw = yaml.safe_load(OPENAPI_YAML)
# {region}/{ver} substituted with their declared defaults
assert spec_base_urls(raw) == ["https://eu.api.shop.test/v1"]
def test_openapi_drops_unresolved_relative_server() -> None:
raw = {"openapi": "3.0.0", "info": {"title": "X"}, "servers": [{"url": "/v2"}]}
# relative URL is not an authorizable host
assert spec_base_urls(raw) == []
def test_swagger_base_urls_built_from_host() -> None:
assert spec_base_urls(SWAGGER_JSON) == ["https://legacy.test/api"]
def test_swagger_without_host_yields_no_base_urls() -> None:
assert spec_base_urls({"swagger": "2.0", "info": {}, "paths": {}}) == []
def test_postman_base_urls_from_request_hosts() -> None:
assert spec_base_urls(POSTMAN_JSON) == ["https://petstore.test"]
def test_spec_base_urls_rejects_unrecognized() -> None:
with pytest.raises(SpecParseError):
spec_base_urls({"foo": 1})
# --- Postman variable / environment resolution ---------------------------
POSTMAN_WITH_VARS = {
"info": {"_postman_id": "v-1", "name": "Var Collection"},
"variable": [{"key": "baseUrl", "value": "https://api.vars.test"}],
"item": [
{"name": "Get thing", "request": {"method": "GET", "url": {"raw": "{{baseUrl}}/things/1"}}}
],
}
POSTMAN_NEEDS_ENV = {
"info": {"_postman_id": "e-1", "name": "Env Collection"},
"item": [
{"name": "Get thing", "request": {"method": "GET", "url": {"raw": "{{baseUrl}}/things/1"}}}
],
}
def test_postman_resolves_collection_variables() -> None:
assert spec_base_urls(POSTMAN_WITH_VARS) == ["https://api.vars.test"]
def test_postman_without_env_leaves_variable_unresolved() -> None:
# {{baseUrl}} never resolves -> no absolute host recovered
assert spec_base_urls(POSTMAN_NEEDS_ENV) == []
def test_postman_environment_values_resolve_base_url() -> None:
resolved = spec_base_urls(
POSTMAN_NEEDS_ENV,
extra_variables={"baseUrl": "https://api.env.test"},
)
assert resolved == ["https://api.env.test"]
# --- Postman API fetch ---------------------------------------------------
class _FakeResponse:
def __init__(self, status_code: int, payload: Any) -> None:
self.status_code = status_code
self._payload = payload
def json(self) -> Any:
return self._payload
def test_fetch_postman_collection_unwraps(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
def fake_get(url: str, headers: dict[str, str], **_kwargs: Any) -> _FakeResponse:
captured["url"] = url
captured["headers"] = headers
return _FakeResponse(200, {"collection": POSTMAN_WITH_VARS})
monkeypatch.setattr(requests, "get", fake_get)
collection = fetch_postman_collection("abc-123", "PMAK-xyz")
assert collection["info"]["name"] == "Var Collection"
assert captured["url"].endswith("/collections/abc-123")
assert captured["headers"]["X-Api-Key"] == "PMAK-xyz"
def test_fetch_postman_missing_key_raises() -> None:
with pytest.raises(SpecParseError, match="POSTMAN_API_KEY"):
fetch_postman_collection("abc-123", "")
def test_fetch_postman_404_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(404, {}))
with pytest.raises(SpecParseError, match="not found"):
fetch_postman_collection("missing", "PMAK-xyz")
def test_fetch_postman_401_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(401, {}))
with pytest.raises(SpecParseError, match="rejected the key"):
fetch_postman_collection("abc-123", "bad-key")
def test_fetch_postman_empty_collection_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(200, {"collection": {}}))
with pytest.raises(SpecParseError, match="empty"):
fetch_postman_collection("abc-123", "PMAK-xyz")
def test_fetch_postman_environment_returns_enabled_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = {
"environment": {
"name": "prod",
"values": [
{"key": "baseUrl", "value": "https://api.env.test", "enabled": True},
{"key": "secretToken", "value": "s3cr3t", "enabled": False},
],
}
}
monkeypatch.setattr(requests, "get", lambda *_a, **_k: _FakeResponse(200, payload))
values = fetch_postman_environment("env-1", "PMAK-xyz")
assert values == {"baseUrl": "https://api.env.test"} # disabled secret excluded
def _dispatch_get(
collection: dict[str, Any],
env: dict[str, Any],
) -> Callable[..., _FakeResponse]:
def fake_get(url: str, **_kwargs: Any) -> _FakeResponse:
if "/environments/" in url:
return _FakeResponse(200, env)
return _FakeResponse(200, {"collection": collection})
return fake_get
def test_fetch_then_resolve_from_environment(monkeypatch: pytest.MonkeyPatch) -> None:
env = {"environment": {"values": [{"key": "baseUrl", "value": "https://api.env.test"}]}}
monkeypatch.setattr(requests, "get", _dispatch_get(POSTMAN_NEEDS_ENV, env))
collection = fetch_postman_collection("coll-1", "PMAK-xyz")
variables = fetch_postman_environment("env-1", "PMAK-xyz")
assert spec_base_urls(collection, extra_variables=variables) == ["https://api.env.test"]
+152
View File
@@ -0,0 +1,152 @@
"""Integration of the ``api_spec`` target type into detection, staging, and inputs."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import pytest
from strix.core.inputs import build_root_task, build_scope_context
from strix.interface.scan_setup import build_targets_info
from strix.interface.utils import infer_target_type, stage_api_specs
OPENAPI = {
"openapi": "3.0.0",
"info": {"title": "Shop API", "version": "1"},
"servers": [{"url": "https://api.shop.test/v1"}],
"paths": {
"/users/{id}": {
"get": {
"summary": "Get user",
"parameters": [{"name": "id", "in": "path", "schema": {"type": "string"}}],
}
}
},
}
def _write_spec(directory: Path, name: str = "openapi.json") -> Path:
directory.mkdir(parents=True, exist_ok=True)
path = directory / name
path.write_text(json.dumps(OPENAPI), encoding="utf-8")
return path
def _resolved_targets(*spec_paths: Path) -> list[dict[str, Any]]:
"""Run spec targets through the real setup path (detection + spec resolution)."""
args = argparse.Namespace(target=[str(p) for p in spec_paths], target_list=None)
build_targets_info(args)
targets: list[dict[str, Any]] = args.targets_info
return targets
def _staged_target(tmp_path: Path, run_name: str = "test-run") -> dict[str, Any]:
targets = _resolved_targets(_write_spec(tmp_path / "src"))
stage_api_specs(targets, run_name)
return targets[0]
def test_infer_target_type_detects_api_spec(tmp_path: Path) -> None:
path = _write_spec(tmp_path)
ttype, details = infer_target_type(str(path))
assert ttype == "api_spec"
assert details["spec_format"] == "openapi"
assert Path(details["target_spec"]).is_absolute()
def test_infer_target_type_still_rejects_non_spec_file(tmp_path: Path) -> None:
path = tmp_path / "data.json"
path.write_text(json.dumps({"foo": "bar"}), encoding="utf-8")
with pytest.raises(ValueError, match="not a directory"):
infer_target_type(str(path))
def test_infer_target_type_detects_postman_uri() -> None:
ttype, details = infer_target_type("postman://12345-abcdef-uid")
assert ttype == "api_spec"
assert details["source"] == "postman_api"
assert details["collection_uid"] == "12345-abcdef-uid"
assert details["spec_format"] == "postman"
def test_infer_target_type_rejects_empty_postman_uri() -> None:
with pytest.raises(ValueError, match="collection id"):
infer_target_type("postman://")
def test_infer_target_type_parses_postman_environment() -> None:
_ttype, details = infer_target_type("postman://coll-uid?env=env-uid")
assert details["collection_uid"] == "coll-uid"
assert details["environment_uid"] == "env-uid"
def test_infer_target_type_postman_without_env_omits_key() -> None:
_ttype, details = infer_target_type("postman://coll-uid")
assert "environment_uid" not in details
def test_build_targets_info_records_title_and_base_urls(tmp_path: Path) -> None:
(target,) = _resolved_targets(_write_spec(tmp_path))
assert target["details"]["spec_title"] == "Shop API"
assert target["details"]["base_urls"] == ["https://api.shop.test/v1"]
def test_build_targets_info_rejects_unparseable_spec(tmp_path: Path) -> None:
path = tmp_path / "openapi.json"
path.write_text('{"openapi": "3.0.0", "info": {"title": "X"}, "paths"', encoding="utf-8")
args = argparse.Namespace(target=[str(path)], target_list=None)
# a broken file is not recognized as a spec, so it fails as an unusable target
with pytest.raises(ValueError, match="Invalid target"):
build_targets_info(args)
def test_stage_api_specs_copies_spec_into_workspace_dir(tmp_path: Path) -> None:
targets = _resolved_targets(_write_spec(tmp_path / "src"))
(source,) = stage_api_specs(targets, "stage-run")
assert source["workspace_subdir"] == "api-specs"
staged = Path(source["source_path"]) / "openapi.json"
assert json.loads(staged.read_text(encoding="utf-8"))["info"]["title"] == "Shop API"
assert targets[0]["details"]["workspace_path"] == "/workspace/api-specs/openapi.json"
def test_stage_api_specs_disambiguates_same_filename(tmp_path: Path) -> None:
targets = _resolved_targets(
_write_spec(tmp_path / "a"),
_write_spec(tmp_path / "b"),
)
(source,) = stage_api_specs(targets, "dupe-run")
staged_paths = [t["details"]["workspace_path"] for t in targets]
assert staged_paths == [
"/workspace/api-specs/openapi.json",
"/workspace/api-specs/openapi-2.json",
]
assert (Path(source["source_path"]) / "openapi-2.json").is_file()
def test_stage_api_specs_without_specs_returns_nothing() -> None:
assert stage_api_specs([{"type": "web_application", "details": {}}], "run") == []
def test_build_root_task_points_at_the_spec_file(tmp_path: Path) -> None:
task = build_root_task({"targets": [_staged_target(tmp_path)]})
assert "API Specifications" in task
assert "Shop API (openapi specification" in task
assert "/workspace/api-specs/openapi.json" in task
assert "https://api.shop.test/v1" in task
assert "test every operation it declares" in task
def test_build_scope_context_authorizes_base_urls(tmp_path: Path) -> None:
context = build_scope_context({"targets": [_staged_target(tmp_path)]})
authorized = context["authorized_targets"]
types = {a["type"] for a in authorized}
assert "api_spec" in types
assert "web_application" in types
assert any(a["value"] == "https://api.shop.test/v1" for a in authorized)
+33
View File
@@ -855,6 +855,39 @@ async def test_structured_provider_refusal_fails_noninteractive_child(
session.close()
@pytest.mark.asyncio
async def test_crashing_noninteractive_child_settles_and_wakes_its_parent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The exception ends the child's task, so its status and the parent's wake-up
# have to be settled on the way out or the parent waits on a dead child.
def _boom(*_args: Any, **_kwargs: Any) -> Any:
raise RuntimeError("sandbox died mid-turn")
monkeypatch.setattr("strix.core.execution.Runner.run_streamed", _boom)
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
with pytest.raises(RuntimeError, match="sandbox died mid-turn"):
await execution._run_cycle(
MagicMock(),
coordinator,
"child",
input_data="task",
run_config=MagicMock(),
context={"parent_id": "root"},
max_turns=5,
session=None,
interactive=False,
event_sink=None,
hooks=None,
)
assert coordinator.statuses["child"] == "crashed"
assert coordinator.pending_counts.get("root", 0) > 0
@pytest.mark.asyncio
async def test_run_agent_loop_seeds_identity_before_first_cycle(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
+2 -1
View File
@@ -8,6 +8,7 @@ from typing import Any
import httpx
import pytest
from agents import ModelSettings
from openai import RateLimitError
import strix.tools.notes.tools as notes_tools
@@ -64,7 +65,7 @@ async def test_persistent_rate_limit_stops_gracefully(
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "")
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object())
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings())
monkeypatch.setattr(runner, "build_strix_agent", lambda **_kwargs: object())
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
+22 -2
View File
@@ -11,6 +11,7 @@ from typing import Any
import httpx
import pytest
from agents import ModelSettings
from openai import RateLimitError
import strix.tools.notes.tools as notes_tools
@@ -74,7 +75,7 @@ def _patch_engine_scaffold(
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: scope_context)
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object())
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: ModelSettings())
captured: dict[str, Any] = {}
@@ -87,7 +88,8 @@ def _patch_engine_scaffold(
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
async def _raise_rate_limit(*_args: Any, **_kwargs: Any) -> None:
async def _raise_rate_limit(*_args: Any, **kwargs: Any) -> None:
captured["run_config"] = kwargs.get("run_config")
raise _make_rate_limit_error()
monkeypatch.setattr(runner, "run_agent_loop", _raise_rate_limit)
@@ -176,3 +178,21 @@ async def test_root_prompt_options_default_to_none(
kwargs = captured["kwargs"]
assert kwargs["instructions_override"] is None
assert kwargs["system_prompt_context"] == {"scope": "built-in"}
@pytest.mark.asyncio
async def test_unknown_tool_calls_are_returned_to_the_model(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
"""A hallucinated tool name must not end the scan."""
captured = _patch_engine_scaffold(monkeypatch, tmp_path, {})
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-unknown-tool",
image="img",
coordinator=AgentCoordinator(),
)
assert captured["run_config"].tool_not_found_behavior == "return_error_to_model"
+155
View File
@@ -0,0 +1,155 @@
"""Tests for surviving a hallucinated tool name.
Models regularly invent tool names that Strix does not register (``read_file``
is a common one, borrowed from other agent frameworks). The SDK's default is to
raise ``ModelBehaviorError``, which ends the whole run: nothing in Strix retries
it, so one bad token discards a scan. The runner therefore opts into
``tool_not_found_behavior="return_error_to_model"`` so the unknown call comes
back as a tool result and the agent corrects itself on the next turn.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING, Any
import pytest
from agents import Agent, Runner, function_tool
from agents.exceptions import ModelBehaviorError
from agents.models.interface import Model, ModelProvider
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.run import RunConfig
from openai import AsyncOpenAI
from strix.config.models import _NonStreamingModel
if TYPE_CHECKING:
from collections.abc import Iterator
_TURNS: list[dict[str, Any]] = []
def _unknown_tool_call_completion() -> dict[str, Any]:
return {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 0,
"model": "gw-model",
"choices": [
{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "read_file",
"arguments": '{"path": "/etc/passwd"}',
},
}
],
},
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
}
def _text_completion(text: str) -> dict[str, Any]:
return {
"id": "chatcmpl-2",
"object": "chat.completion",
"created": 0,
"model": "gw-model",
"choices": [
{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
class _Handler(BaseHTTPRequestHandler):
"""Calls an unregistered tool on turn 1, then answers on turn 2."""
def log_message(self, *args: Any) -> None:
pass
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", 0))
_TURNS.append(json.loads(self.rfile.read(length) or b"{}"))
completion = (
_unknown_tool_call_completion() if len(_TURNS) == 1 else _text_completion("recovered")
)
payload = json.dumps(completion).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
@pytest.fixture
def gateway_url() -> Iterator[str]:
_TURNS.clear()
server = HTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/v1"
finally:
server.shutdown()
server.server_close()
def _agent() -> Agent[Any]:
@function_tool
def real_tool(n: int) -> str:
return f"did {n}"
return Agent(name="Strix", instructions="test", tools=[real_tool], model="gw-model")
def _run_config(base_url: str, **kwargs: Any) -> RunConfig:
class _Provider(ModelProvider):
def get_model(self, model_name: str | None) -> Model: # noqa: ARG002
client = AsyncOpenAI(api_key="tok", base_url=base_url)
return _NonStreamingModel(OpenAIChatCompletionsModel("gw-model", openai_client=client))
return RunConfig(model_provider=_Provider(), **kwargs)
@pytest.mark.asyncio
async def test_unknown_tool_call_is_returned_to_the_model(gateway_url: str) -> None:
result = Runner.run_streamed(
_agent(),
input="go",
run_config=_run_config(gateway_url, tool_not_found_behavior="return_error_to_model"),
)
async for _ in result.stream_events():
pass
assert result.final_output == "recovered"
# The second turn carries the error back to the model as a tool result.
tool_results = [
item
for item in _TURNS[1]["messages"]
if item.get("role") == "tool" and item.get("tool_call_id") == "call_1"
]
assert tool_results
assert "read_file" in str(tool_results[0]["content"])
@pytest.mark.asyncio
async def test_unknown_tool_call_kills_the_run_without_the_setting(gateway_url: str) -> None:
result = Runner.run_streamed(_agent(), input="go", run_config=_run_config(gateway_url))
with pytest.raises(ModelBehaviorError, match="read_file"):
async for _ in result.stream_events():
pass
Generated
+66 -66
View File
@@ -469,55 +469,52 @@ wheels = [
[[package]]
name = "cryptography"
version = "48.0.1"
version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
{ url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
{ url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
{ url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
]
[[package]]
@@ -1105,7 +1102,7 @@ name = "macholib"
version = "1.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "altgraph", marker = "python_full_version < '3.15' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [
@@ -1384,7 +1381,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.44.0"
version = "2.53.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1396,14 +1393,14 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" },
{ url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" },
]
[[package]]
name = "openai-agents"
version = "0.14.6"
version = "0.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "griffelib" },
@@ -1411,13 +1408,12 @@ dependencies = [
{ name = "openai" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "types-requests" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/4f859d13ba5eea5fe5a3166ffeed04bd04d478ccf3187da6acebb17ba2a7/openai_agents-0.14.6.tar.gz", hash = "sha256:e9d16b835f73be4c5e3798694f90d7a62efcade931e59416bc7462c850e15705", size = 5311175, upload-time = "2026-04-25T02:32:00.897Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/6c/8fa83cb23d2fe864b284cb45acf895d72a2f6e9827cc684dd4ef0d02d414/openai_agents-0.19.0.tar.gz", hash = "sha256:1d519d6966834e5c04160caec3a2549190e92ce50cdeb22fac5e17e67b8b98b2", size = 5620718, upload-time = "2026-07-27T22:49:26.615Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/96/b49d04e860c79699814289c273e88066ce97a50686172b5733b7458da062/openai_agents-0.14.6-py3-none-any.whl", hash = "sha256:fdd3fb459892c8af5d0b522908b544e96f6217c7254ba55e966424493b43c1ed", size = 816112, upload-time = "2026-04-25T02:31:58.976Z" },
{ url = "https://files.pythonhosted.org/packages/3b/3a/bac1aa3405c0f11b4334ac999e881d08fa40fad1f3b7229a1ca222ada489/openai_agents-0.19.0-py3-none-any.whl", hash = "sha256:25392cff993eca7c75b0679ec8d0111faef20c517222c0193111097d0f70db7a", size = 928463, upload-time = "2026-07-27T22:49:24.405Z" },
]
[package.optional-dependencies]
@@ -1799,13 +1795,13 @@ name = "pyinstaller"
version = "6.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
{ name = "altgraph", marker = "python_full_version < '3.15'" },
{ name = "macholib", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "packaging", marker = "python_full_version < '3.15'" },
{ name = "pefile", marker = "python_full_version < '3.15' and sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib", marker = "python_full_version < '3.15'" },
{ name = "pywin32-ctypes", marker = "python_full_version < '3.15' and sys_platform == 'win32'" },
{ name = "setuptools", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" }
wheels = [
@@ -1827,7 +1823,7 @@ name = "pyinstaller-hooks-contrib"
version = "2026.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "packaging", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" }
wheels = [
@@ -2392,6 +2388,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "pyyaml" },
{ name = "reportlab" },
{ name = "requests" },
{ name = "rich" },
@@ -2415,22 +2412,24 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "types-requests" },
]
[package.metadata]
requires-dist = [
{ name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.28.0" },
{ name = "caido-sdk-client", specifier = ">=0.2.0" },
{ name = "cryptography", specifier = ">=48.0.1,<49" },
{ name = "cryptography", specifier = ">=48.0.1,<51" },
{ name = "cvss", specifier = ">=3.2" },
{ name = "docker", specifier = ">=7.1.0" },
{ name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" },
{ name = "litellm" },
{ name = "openai", specifier = ">=2.26.0,<2.45" },
{ name = "openai-agents", extras = ["litellm"], specifier = "==0.14.6" },
{ name = "openai", specifier = ">=2.45.0,<3" },
{ name = "openai-agents", extras = ["litellm"], specifier = ">=0.19.0,<0.20" },
{ name = "pydantic", specifier = ">=2.11.3" },
{ name = "pydantic-settings", specifier = ">=2.13.0" },
{ name = "pypdf", specifier = ">=5.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "reportlab", specifier = ">=4.0" },
{ name = "requests", specifier = ">=2.32.0" },
{ name = "rich" },
@@ -2447,6 +2446,7 @@ dev = [
{ name = "pytest", specifier = ">=8.3" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "ruff", specifier = ">=0.11.13" },
{ name = "types-requests", specifier = ">=2.32" },
]
[[package]]
@@ -2552,14 +2552,14 @@ wheels = [
[[package]]
name = "types-requests"
version = "2.33.0.20260518"
version = "2.33.0.20260712"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" }
sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" },
]
[[package]]