mirror of
https://github.com/usestrix/strix.git
synced 2026-11-09 18:08:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ffc6c66c3 | ||
|
|
3d7fa144d5 | ||
|
|
18fa4c8449 | ||
|
|
215482eb22 | ||
|
|
0601550662 | ||
|
|
851743adcf |
No files matched your search
@@ -1,48 +1,39 @@
|
||||
name: Package
|
||||
name: Binary
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- strix.spec
|
||||
- scripts/**
|
||||
- strix/interface/tui/**
|
||||
- .github/actions/**
|
||||
- .github/workflows/binary.yml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- strix.spec
|
||||
- scripts/**
|
||||
- strix/interface/tui/**
|
||||
- .github/actions/**
|
||||
- .github/workflows/binary.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
wheel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: strix/interface/tui/go.mod
|
||||
cache-dependency-path: strix/interface/tui/go.sum
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv build --wheel
|
||||
- name: Wheel ships the TUI sidecar and the viewer bundle
|
||||
run: |
|
||||
uv run --frozen python - <<'PY'
|
||||
import glob, zipfile
|
||||
(wheel,) = glob.glob("dist/*.whl")
|
||||
names = zipfile.ZipFile(wheel).namelist()
|
||||
assert "strix/bin/strix-tui" in names, names
|
||||
assert "strix/interface/viewer/static/index.html" in names, names
|
||||
assert not any(name.startswith("strix/interface/viewer/frontend/") for name in names)
|
||||
assert not any(name.startswith("strix/interface/tui/cmd/") for name in names)
|
||||
PY
|
||||
- name: Install into a clean environment and run the CLI
|
||||
env:
|
||||
UV_EXCLUDE_NEWER: 5 days
|
||||
run: |
|
||||
uv venv --python 3.12 /tmp/strix-smoke
|
||||
uv pip install --python /tmp/strix-smoke/bin/python dist/*.whl
|
||||
/tmp/strix-smoke/bin/strix --version
|
||||
/tmp/strix-smoke/bin/strix --help >/dev/null
|
||||
- name: Vertex AI and Bedrock dependencies install with the wheel
|
||||
run: /tmp/strix-smoke/bin/python -c "import google.auth.transport.requests, boto3"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
binary:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
@@ -32,6 +32,7 @@ jobs:
|
||||
wheel-platform: win_amd64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
@@ -110,6 +111,7 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
name: Python
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
- run: uv lock --check
|
||||
|
||||
ruff:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv run --frozen ruff check .
|
||||
- run: uv run --frozen ruff format --check .
|
||||
|
||||
mypy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv run --frozen mypy strix/
|
||||
|
||||
bandit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv run --frozen bandit -r strix/ -c pyproject.toml
|
||||
|
||||
pytest:
|
||||
name: pytest (${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.12', '3.13', '3.14']
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: uv run --frozen pytest -q
|
||||
@@ -1,26 +0,0 @@
|
||||
name: TUI
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
go:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: strix/interface/tui
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: strix/interface/tui/go.mod
|
||||
cache-dependency-path: strix/interface/tui/go.sum
|
||||
- run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: CGO_ENABLED=0 go build -trimpath -o /dev/null ./cmd/strix-tui
|
||||
- run: go test -race ./...
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Viewer
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: strix/interface/viewer/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: strix/interface/viewer/frontend/package-lock.json
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- name: Committed viewer bundle matches the build
|
||||
run: git -C "$GITHUB_WORKSPACE" diff --exit-code --stat -- strix/interface/viewer/static && test -z "$(git -C "$GITHUB_WORKSPACE" status --porcelain -- strix/interface/viewer/static)"
|
||||
+97
-28
@@ -15,38 +15,107 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
python:
|
||||
uses: ./.github/workflows/ci-python.yml
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv lock --check
|
||||
- run: uv run --frozen ruff check .
|
||||
- run: uv run --frozen ruff format --check .
|
||||
- run: uv run --frozen mypy strix/
|
||||
- run: uv run --frozen bandit -r strix/ -c pyproject.toml
|
||||
|
||||
pytest:
|
||||
name: pytest (${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.12', '3.14']
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: uv run --frozen pytest -q
|
||||
|
||||
tui:
|
||||
uses: ./.github/workflows/ci-tui.yml
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: strix/interface/tui
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: strix/interface/tui/go.mod
|
||||
cache-dependency-path: strix/interface/tui/go.sum
|
||||
- run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: CGO_ENABLED=0 go build -trimpath -o /dev/null ./cmd/strix-tui
|
||||
- run: go test -race ./...
|
||||
|
||||
viewer:
|
||||
uses: ./.github/workflows/ci-viewer.yml
|
||||
|
||||
package:
|
||||
uses: ./.github/workflows/ci-package.yml
|
||||
|
||||
workflows:
|
||||
uses: ./.github/workflows/ci-workflows.yml
|
||||
|
||||
ci-passed:
|
||||
name: ci-passed
|
||||
if: always()
|
||||
needs:
|
||||
- python
|
||||
- tui
|
||||
- viewer
|
||||
- package
|
||||
- workflows
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: strix/interface/viewer/frontend
|
||||
steps:
|
||||
- name: Every job succeeded
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: strix/interface/viewer/frontend/package-lock.json
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- name: Committed viewer bundle matches the build
|
||||
run: git -C "$GITHUB_WORKSPACE" diff --exit-code --stat -- strix/interface/viewer/static && test -z "$(git -C "$GITHUB_WORKSPACE" status --porcelain -- strix/interface/viewer/static)"
|
||||
|
||||
wheel:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: strix/interface/tui/go.mod
|
||||
cache-dependency-path: strix/interface/tui/go.sum
|
||||
- uses: ./.github/actions/setup-python-env
|
||||
- run: uv build --wheel
|
||||
- name: Wheel ships the TUI sidecar and the viewer bundle
|
||||
run: |
|
||||
failing=$(jq -r 'to_entries[] | select(.value.result != "success") | "\(.key): \(.value.result)"' <<< "$NEEDS")
|
||||
if [ -n "$failing" ]; then
|
||||
echo "$failing"
|
||||
exit 1
|
||||
fi
|
||||
uv run --frozen python - <<'PY'
|
||||
import glob, zipfile
|
||||
(wheel,) = glob.glob("dist/*.whl")
|
||||
names = zipfile.ZipFile(wheel).namelist()
|
||||
assert "strix/bin/strix-tui" in names, names
|
||||
assert "strix/interface/viewer/static/index.html" in names, names
|
||||
assert not any(name.startswith("strix/interface/viewer/frontend/") for name in names)
|
||||
assert not any(name.startswith("strix/interface/tui/cmd/") for name in names)
|
||||
PY
|
||||
- name: Install into a clean environment and run the CLI
|
||||
env:
|
||||
UV_EXCLUDE_NEWER: 5 days
|
||||
run: |
|
||||
uv venv --python 3.12 /tmp/strix-smoke
|
||||
uv pip install --python /tmp/strix-smoke/bin/python dist/*.whl
|
||||
/tmp/strix-smoke/bin/strix --version
|
||||
/tmp/strix-smoke/bin/strix --help >/dev/null
|
||||
- name: Vertex AI and Bedrock dependencies install with the wheel
|
||||
run: /tmp/strix-smoke/bin/python -c "import google.auth.transport.requests, boto3"
|
||||
@@ -1,14 +1,27 @@
|
||||
name: Workflows
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/**
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
ACTIONLINT_SCRIPT_REF: 914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
||||
@@ -27,6 +40,7 @@ jobs:
|
||||
|
||||
zizmor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
@@ -45,23 +45,3 @@ repos:
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: [-c, pyproject.toml]
|
||||
|
||||
# Additional Python code quality checks
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
|
||||
ci:
|
||||
autofix_commit_msg: |
|
||||
[pre-commit.ci] auto fixes from pre-commit.com hooks
|
||||
|
||||
for more information, see https://pre-commit.ci
|
||||
autofix_prs: true
|
||||
autoupdate_branch: ""
|
||||
autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate"
|
||||
autoupdate_schedule: weekly
|
||||
# pre-commit.ci cannot run `language: system` hooks; mypy runs via `make check-all`.
|
||||
skip: [mypy]
|
||||
submodules: false
|
||||
@@ -64,6 +64,8 @@ Test every input vector with every applicable technique.
|
||||
|
||||
**Input Handling**
|
||||
- Multiple injection types: SQL, NoSQL, LDAP, XPath, command, template
|
||||
- For scalar search/filter/selector fields—including authorization fields—the delegated task must list each field and preload `query_language_injection`. One owner must run its matrix before `exact match` or `ruled out`
|
||||
- Where different components validate and consume structured input, preload `idor`. Test raw parser differentials (duplicate members, type/number boundaries) and compare the checked identity with the acted-on identity
|
||||
- Encoding bypasses: double encoding, unicode, null bytes
|
||||
- Boundary conditions and type confusion
|
||||
- Large payloads and buffer-related issues
|
||||
|
||||
@@ -50,6 +50,8 @@ Test each attack surface methodically. Spawn focused subagents for different are
|
||||
- Injection testing on all input fields (SQL, XSS, command, template)
|
||||
- File upload bypass attempts
|
||||
- Search and filter parameter manipulation
|
||||
- For every scalar search/filter/selector field, the delegated task must name it, preload `query_language_injection`, and require that skill's per-field matrix. Sibling-field probes or standalone metacharacters cannot justify `exact match`
|
||||
- For structured authorization/object identifiers, preload `idor` and require raw duplicate-member permutations plus safe-number boundaries, comparing the checked identity with the read/changed/exported identity
|
||||
- Redirect and URL parameter handling
|
||||
|
||||
**Authentication & Session**
|
||||
|
||||
@@ -207,7 +207,7 @@ agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Credentials in shell history are a leak. For anything sensitive, use the
|
||||
auth vault (see [references/authentication.md](references/authentication.md)):
|
||||
auth vault (see `references/authentication.md`):
|
||||
|
||||
```bash
|
||||
agent-browser auth save my-app --url https://app.example.com/login \
|
||||
@@ -367,7 +367,7 @@ agent-browser click @e3
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
See [references/video-recording.md](references/video-recording.md) for
|
||||
See `references/video-recording.md` for
|
||||
codec options, GIF export, and more.
|
||||
|
||||
### Iframes
|
||||
@@ -491,8 +491,8 @@ snapshot — fall back to `eval` in the iframe's origin or use the
|
||||
|
||||
**Authentication expires mid-workflow**
|
||||
Use `--session-name <name>` or `state save`/`state load` so your session
|
||||
survives browser restarts. See [references/session-management.md](references/session-management.md)
|
||||
and [references/authentication.md](references/authentication.md).
|
||||
survives browser restarts. See `references/session-management.md`
|
||||
and `references/authentication.md`.
|
||||
|
||||
## Global flags worth knowing
|
||||
|
||||
|
||||
@@ -53,11 +53,13 @@ Object-level authorization failures (BOLA/IDOR) lead to cross-account data expos
|
||||
|
||||
**Enumeration Techniques**
|
||||
- Alternate types: `{"id":123}` vs `{"id":"123"}`, arrays vs scalars, objects vs scalars
|
||||
- Edge values: null/empty/0/-1/MAX_INT, scientific notation, overflows
|
||||
- Duplicate keys/parameter pollution: `id=1&id=2`, JSON duplicate keys `{"id":1,"id":2}` (parser precedence)
|
||||
- Edge and numeric boundaries: null, empty, 0, -1, MAX_INT, and exact and adjacent values around each runtime's safe-integer and overflow limits, including decimal and exponent forms
|
||||
- Duplicate keys/parameter pollution: send raw query strings such as `id=1&id=2` and raw JSON such as `{"id":1,"id":2}` so serializers cannot collapse duplicates. Use conflicting values in both orders to determine parser precedence
|
||||
- Case/aliasing: userId vs userid vs USER_ID; alt names like resourceId, targetId, account
|
||||
- Path traversal-like in virtual file systems: `/files/user_123/../../user_456/report.csv`
|
||||
|
||||
For gateways, workers, and multi-service APIs, treat authorization as an identity invariant: the object authorized must equal the object read, changed, or exported. Run the representation and duplicate-member matrix even after one variant succeeds. Compare response identity/content, not status alone.
|
||||
|
||||
**UUID/Opaque ID Sources**
|
||||
- Logs, exports, JS bundles, analytics endpoints, emails, public activity
|
||||
- Time-based IDs (UUIDv1, ULID) may be guessable within a window
|
||||
|
||||
@@ -14,6 +14,7 @@ NoSQL injection exploits the mismatch between how applications pass user input t
|
||||
- Form fields with bracket notation (`field[$ne]=`) coerced into operator objects by Express, PHP, and similar middleware
|
||||
- URL-encoded JSON in query strings, headers, and cookies
|
||||
- GraphQL variables passed directly into resolver-level NoSQL filters
|
||||
- Scalar strings compiled into expression languages are a separate construction class. Load `query_language_injection`
|
||||
|
||||
**Code patterns that enable injection**
|
||||
- Raw filter dicts/objects from user input handed to `find`/`findOne`/`aggregate`
|
||||
@@ -241,7 +242,7 @@ DoS surface (use only with explicit authorization scope):
|
||||
|
||||
1. **Identify query-receiving endpoints** — login, search, filter, lookup
|
||||
2. **Determine input format** — JSON body vs form fields vs URL params
|
||||
3. **Send error-probing payloads** — malformed operator objects; watch for MongoDB/driver errors
|
||||
3. **Send error-probing payloads** — test malformed operator objects and watch for MongoDB/driver errors. For scalar expression construction, load `query_language_injection`
|
||||
4. **Attempt operator injection** — `$ne`, `$gt`, `$regex` against login endpoint
|
||||
5. **Confirm boolean oracle** — response, status, redirect differs between true/false predicates
|
||||
6. **Extract data blindly** — character-by-character `$regex` on sensitive fields (token, reset code)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: query-language-injection
|
||||
description: Scalar injection into embedded query languages, especially apparent exact-match authorization filters compiled as JSONPath, JMESPath, OData, Lucene, LDAP, Cypher, or XPath
|
||||
---
|
||||
|
||||
# Query-Language Injection
|
||||
|
||||
An HTTP string can become syntax when a downstream component builds a query or policy expression. Treat every accepted search, filter, selector, and authorization scalar as an independent sink.
|
||||
|
||||
## Mandatory Matrix
|
||||
|
||||
For each named scalar field:
|
||||
|
||||
1. Establish known-match and known-no-match controls while all sibling fields stay fixed.
|
||||
2. With known match `V`, execute these four JSONPath-style closure cells on the field itself: `V' || @.<field> == @.<field> || 'V`, `V' && @.<field> != @.<field> && 'V`, `V" || @.<field> == @.<field> || "V`, and `V" && @.<field> != @.<field> && "V`. Balance the expected suffix and adapt field and operator syntax for other grammars. Constant-only expressions may be rejected.
|
||||
3. Do not substitute standalone metacharacters, generic SQL probes, object payloads, or tests of a sibling field for any cell.
|
||||
4. Repeat across plausible grammars inferred from behavior or errors. An empty result may represent a swallowed parser exception.
|
||||
5. Confirm impact through broadened, cross-tenant, privileged, or otherwise forbidden results—not a parser error alone.
|
||||
|
||||
Do not conclude `exact match`, `safe`, or `ruled out` until this matrix ran on every scalar sink, or a specific non-concatenating control was identified.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: semantic-confusion
|
||||
description: Cross-component semantic confusion testing for parser differentials, normalization mismatches, overloaded fields, lifecycle state drift, internal redirects, protocol translation, and validator-to-sink inconsistencies
|
||||
description: Cross-component semantic confusion testing for parser differentials, normalization mismatches, email and IDNA identity drift, overloaded fields, lifecycle state drift, internal redirects, protocol translation, and validator-to-sink inconsistencies
|
||||
---
|
||||
|
||||
# Semantic Confusion
|
||||
@@ -59,6 +59,27 @@ The highest-signal condition is `security_check(value_A)` followed by `sink(tran
|
||||
- Test malformed Unicode and replacement behavior; a rejected code point may become an allowed delimiter or wildcard later.
|
||||
- Test path, query, and fragment separately. Browsers and routers commonly transform each source differently.
|
||||
|
||||
### Email and Account Identity Drift
|
||||
|
||||
Treat an email address as both a principal selector and a delivery destination. Trace the complete boundary before testing:
|
||||
|
||||
```text
|
||||
submitted or identity-provider address
|
||||
-> application normalization / IDNA
|
||||
-> database comparison and collation
|
||||
-> selected local principal
|
||||
-> token, session, invitation, or link binding
|
||||
-> recipient or linked identity consumed by the final service
|
||||
```
|
||||
|
||||
- Cover recovery, magic links, invitations, registration uniqueness, email changes, and OAuth/OIDC account linking. Test case/accent-insensitive collations, Unicode normalization, IDNA U-label/A-label conversion, trailing dots, and provider canonicalization only when the observed stack supports them.
|
||||
- In black-box testing, report the observed equivalence and routing behavior. Do not name a specific collation, normalizer, or provider algorithm without source, configuration, or version evidence.
|
||||
- Determine whether two non-identical addresses can select the same protected principal while SMTP or an identity provider routes them to different owners. The address that receives authority must identify the same principal selected by the security check.
|
||||
- In recovery flows, compare the submitted address with the selected account's stored canonical address. A fuzzy lookup followed by delivery to the submitted value is high risk; an exact lookup plus delivery to the stored value is the primary negative control.
|
||||
- Use only an explicitly authorized attacker-controlled mailbox or test domain. Derive a bounded variant set from the deployed collation and canonicalizers rather than spraying external domains or the Unicode range.
|
||||
|
||||
For a recovery, magic-link, or invitation finding, a generic success response is not proof. Demonstrate that the variant selects the protected principal, the issued authority reaches the controlled mailbox or identity, and redemption yields the protected principal's access. If delivery or redemption cannot be observed, keep the path `needs_follow_up`.
|
||||
|
||||
### Field and Type Overloading
|
||||
|
||||
- Identify shared fields reused for different concepts: path vs URL, content type vs handler, display name vs executable name, route vs filesystem location.
|
||||
@@ -170,6 +191,8 @@ A valid confusion finding should include:
|
||||
- Different error messages with identical final authorization and sink behavior
|
||||
- A parser accepts odd syntax but downstream consumers preserve the same safe meaning
|
||||
- A normalization difference visible only in logs, with no security decision between representations
|
||||
- A Unicode or Punycode lookalike that neither selects another principal nor receives authority intended for that principal
|
||||
- A permissive database comparison where recovery delivery still uses the selected account's stored canonical address
|
||||
- WAF bypass where the application itself rejects the request identically
|
||||
- Version-specific behavior claimed as universal without testing the relevant deployment
|
||||
- A search-path candidate that is attacker-named but cannot be created, claimed, loaded, or executed
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""In-memory bearer refresh for HTTP MCP connections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RefreshingBearerAuth(httpx.Auth):
|
||||
def __init__(
|
||||
self, token: str | None, refresh_token: Callable[[], Awaitable[str | None]]
|
||||
) -> None:
|
||||
self._token = token
|
||||
self._refresh_token = refresh_token
|
||||
self._lock = asyncio.Lock()
|
||||
self._refresh_generation = 0
|
||||
|
||||
def _authorize(self, request: httpx.Request, token: str | None) -> None:
|
||||
if token:
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
else:
|
||||
request.headers.pop("Authorization", None)
|
||||
|
||||
async def async_auth_flow(
|
||||
self, request: httpx.Request
|
||||
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||
sent_token = self._token
|
||||
sent_generation = self._refresh_generation
|
||||
self._authorize(request, sent_token)
|
||||
response = yield request
|
||||
if response.status_code != 401:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._token == sent_token and self._refresh_generation == sent_generation:
|
||||
try:
|
||||
token = await self._refresh_token()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("MCP bearer refresh failed (%s)", type(exc).__name__)
|
||||
token = None
|
||||
self._refresh_generation += 1
|
||||
if token and token != sent_token:
|
||||
self._token = token
|
||||
retry_token = self._token
|
||||
if retry_token and retry_token != sent_token:
|
||||
self._authorize(request, retry_token)
|
||||
yield request
|
||||
@@ -39,10 +39,11 @@ from strix.tools.mcp.session import McpConnectionUnavailableError, SupervisedMcp
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
|
||||
import httpx
|
||||
|
||||
from strix.tools.mcp.auth import RefreshingBearerAuth
|
||||
from strix.tools.mcp.config import McpConnectionConfig
|
||||
from strix.tools.mcp.registry import McpConnectionRequest, McpRegistry
|
||||
|
||||
@@ -121,7 +122,9 @@ class _QuietMCPServerStdio(MCPServerStdio):
|
||||
return _quiet_stdio_streams(self.params)
|
||||
|
||||
|
||||
def _build_server(config: McpConnectionConfig) -> BuiltMcpServer:
|
||||
def _build_server(
|
||||
config: McpConnectionConfig, bearer_auth: RefreshingBearerAuth | None = None
|
||||
) -> BuiltMcpServer:
|
||||
"""Construct (but do not connect) the SDK server for one connection.
|
||||
|
||||
The returned tuple carries the server and, for HTTP connections, a recorder
|
||||
@@ -161,13 +164,13 @@ def _build_server(config: McpConnectionConfig) -> BuiltMcpServer:
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
|
||||
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=bearer_auth or auth)
|
||||
client.event_hooks.setdefault("response", []).append(recorder)
|
||||
return client
|
||||
|
||||
http_params: MCPServerStreamableHttpParams = {
|
||||
"url": cast("str", config.url),
|
||||
"headers": _auth_headers(config),
|
||||
"headers": {} if bearer_auth is not None else _auth_headers(config),
|
||||
"timeout": config.http_timeout_seconds,
|
||||
"sse_read_timeout": config.sse_read_timeout_seconds,
|
||||
"httpx_client_factory": httpx_client_factory,
|
||||
@@ -300,9 +303,12 @@ async def connect_mcp_servers(
|
||||
configs: list[McpConnectionConfig],
|
||||
*,
|
||||
max_concurrency: int = 6,
|
||||
token_refreshers: Mapping[str, Callable[[], Awaitable[str | None]]] | None = None,
|
||||
) -> list[ConnectedMcpServer]:
|
||||
"""Connect MCP configs concurrently under a fixed bound.
|
||||
|
||||
``token_refreshers`` optionally supplies a bearer refresher by connection name.
|
||||
|
||||
Each connection becomes a :class:`~strix.tools.mcp.session.SupervisedMcpSession`
|
||||
that owns ``connect()``, the held-open session, and ``cleanup()`` on one
|
||||
dedicated task, so a later background failure in one session is contained to
|
||||
@@ -325,7 +331,10 @@ async def connect_mcp_servers(
|
||||
|
||||
async def connect_one(config: McpConnectionConfig) -> ConnectedMcpServer | None:
|
||||
async with semaphore:
|
||||
session = SupervisedMcpSession(config)
|
||||
session = SupervisedMcpSession(
|
||||
config,
|
||||
refresh_token=token_refreshers.get(config.name) if token_refreshers else None,
|
||||
)
|
||||
sessions.append(session)
|
||||
try:
|
||||
if not await session.start():
|
||||
@@ -381,7 +390,14 @@ async def attach_mcp_requests(
|
||||
ends).
|
||||
"""
|
||||
request_by_name = {request.config.name: request for request in requests}
|
||||
connections = await connect_mcp_servers([request.config for request in requests])
|
||||
connections = await connect_mcp_servers(
|
||||
[request.config for request in requests],
|
||||
token_refreshers={
|
||||
request.config.name: request.refresh_token
|
||||
for request in requests
|
||||
if request.refresh_token is not None
|
||||
},
|
||||
)
|
||||
for connection in connections:
|
||||
request = request_by_name[connection.name]
|
||||
registry.add(
|
||||
@@ -391,5 +407,6 @@ async def attach_mcp_requests(
|
||||
purpose=request.purpose or connection.notes,
|
||||
provider=request.provider,
|
||||
result_transform=request.result_transform,
|
||||
refresh_token=request.refresh_token,
|
||||
)
|
||||
return connections
|
||||
@@ -129,12 +129,20 @@ def classify(exc: BaseException) -> FailureInfo:
|
||||
|
||||
|
||||
class HttpStatusRecorder:
|
||||
"""Capture the last non-success response from one HTTP connection."""
|
||||
"""Capture the last non-success response from one HTTP connection.
|
||||
|
||||
A later success clears a recorded 401, so a bearer refreshed and retried
|
||||
in the auth flow does not leave a stale auth failure behind.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._failure: FailureInfo | None = None
|
||||
|
||||
async def __call__(self, response: httpx.Response) -> None:
|
||||
if 200 <= response.status_code < 300:
|
||||
if self._failure is not None and self._failure.kind == "auth":
|
||||
self._failure = None
|
||||
return
|
||||
if not 200 <= response.status_code < 300:
|
||||
request = response.request
|
||||
self._failure = _from_status(
|
||||
|
||||
@@ -32,7 +32,7 @@ from strix.tools.mcp.session import McpConnectionUnavailableError, SupervisedMcp
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from agents.mcp import MCPServer
|
||||
|
||||
@@ -96,6 +96,9 @@ class McpConnectionEntry:
|
||||
tool_count: int = 0
|
||||
result_transform: ResultTransform | None = None
|
||||
provider: str | None = None
|
||||
refresh_token: Callable[[], Awaitable[str | None]] | None = dataclasses.field(
|
||||
default=None, repr=False
|
||||
)
|
||||
state: McpConnectionState = "configured"
|
||||
_catalog: list[Any] | None = dataclasses.field(default=None, repr=False)
|
||||
_connect_task: asyncio.Task[SupervisedMcpSession] | None = dataclasses.field(
|
||||
@@ -180,7 +183,7 @@ class McpConnectionEntry:
|
||||
f"MCP connection {self.name!r} has no connection configuration."
|
||||
)
|
||||
self._set_state("connecting")
|
||||
session = SupervisedMcpSession(config)
|
||||
session = SupervisedMcpSession(config, refresh_token=self.refresh_token)
|
||||
try:
|
||||
started = await session.start()
|
||||
except BaseException:
|
||||
@@ -298,12 +301,17 @@ class McpConnectionRequest:
|
||||
at the single dispatch point (strix-pro's sanitizer; empty for the
|
||||
command-line path). ``purpose`` is the human label ``list_mcps`` reports as the
|
||||
connection's description; when unset it falls back to ``config.notes``.
|
||||
``refresh_token`` optionally fetches a new bearer asynchronously after an HTTP
|
||||
401; returning no token or the current token leaves the failure unchanged.
|
||||
"""
|
||||
|
||||
config: McpConnectionConfig
|
||||
provider: str | None = None
|
||||
result_transform: ResultTransform | None = None
|
||||
purpose: str | None = None
|
||||
refresh_token: Callable[[], Awaitable[str | None]] | None = dataclasses.field(
|
||||
default=None, repr=False
|
||||
)
|
||||
|
||||
|
||||
class McpCallInfo(NamedTuple):
|
||||
@@ -337,6 +345,7 @@ class McpRegistry:
|
||||
purpose=request.purpose or request.config.notes,
|
||||
result_transform=request.result_transform,
|
||||
provider=request.provider,
|
||||
refresh_token=request.refresh_token,
|
||||
)
|
||||
entry.set_status_sink(self._status_sink)
|
||||
self._entries[entry.name] = entry
|
||||
@@ -353,6 +362,7 @@ class McpRegistry:
|
||||
tool_count: int = 0,
|
||||
result_transform: ResultTransform | None = None,
|
||||
provider: str | None = None,
|
||||
refresh_token: Callable[[], Awaitable[str | None]] | None = None,
|
||||
) -> McpConnectionEntry:
|
||||
"""Register one connection under ``name`` (last write wins).
|
||||
|
||||
@@ -374,6 +384,7 @@ class McpRegistry:
|
||||
tool_count=tool_count,
|
||||
result_transform=result_transform,
|
||||
provider=provider,
|
||||
refresh_token=refresh_token,
|
||||
state="connected",
|
||||
)
|
||||
entry.set_status_sink(self._status_sink)
|
||||
|
||||
@@ -54,6 +54,7 @@ import time
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from strix.tools.mcp.auth import RefreshingBearerAuth
|
||||
from strix.tools.mcp.config import DEFAULT_MAX_CONCURRENT_CALLS
|
||||
from strix.tools.mcp.failures import FailureInfo, HttpStatusRecorder, classify
|
||||
|
||||
@@ -157,9 +158,19 @@ class SupervisedMcpSession:
|
||||
:attr:`name`, :attr:`server`, :attr:`config`, :attr:`is_dead`.
|
||||
"""
|
||||
|
||||
def __init__(self, config: McpConnectionConfig) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
config: McpConnectionConfig,
|
||||
*,
|
||||
refresh_token: Callable[[], Awaitable[str | None]] | None = None,
|
||||
) -> None:
|
||||
self._name = config.name
|
||||
self._config: McpConnectionConfig | None = config
|
||||
self._bearer_auth = (
|
||||
RefreshingBearerAuth(config.auth.token if config.auth else None, refresh_token)
|
||||
if refresh_token is not None and config.transport == "http"
|
||||
else None
|
||||
)
|
||||
self._server: MCPServer | None = None
|
||||
self._supervised = True
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
@@ -193,6 +204,7 @@ class SupervisedMcpSession:
|
||||
self = cls.__new__(cls)
|
||||
self._name = name
|
||||
self._config = config
|
||||
self._bearer_auth = None
|
||||
self._server = server
|
||||
self._supervised = False
|
||||
self._task = None
|
||||
@@ -701,7 +713,11 @@ class SupervisedMcpSession:
|
||||
|
||||
if self._config is None:
|
||||
raise RuntimeError(f"MCP connection {self._name!r} has no config to connect")
|
||||
built = _build_server(self._config)
|
||||
built = (
|
||||
_build_server(self._config, self._bearer_auth)
|
||||
if self._bearer_auth is not None
|
||||
else _build_server(self._config)
|
||||
)
|
||||
server = built.server
|
||||
self._recorder = built.recorder
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""HTTP bearer refresh regressions without network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp.types import CallToolResult, TextContent, Tool
|
||||
|
||||
from strix.tools.mcp import BearerAuth, McpConnectionConfig, McpConnectionRequest, McpRegistry
|
||||
from strix.tools.mcp import client as mcp_client
|
||||
from strix.tools.mcp import session as mcp_session
|
||||
from strix.tools.mcp.auth import RefreshingBearerAuth
|
||||
|
||||
|
||||
def _config() -> McpConnectionConfig:
|
||||
return McpConnectionConfig(
|
||||
name="refresh",
|
||||
url="https://example.test/mcp",
|
||||
auth=BearerAuth(token="old"), # noqa: S106 # nosec B106
|
||||
)
|
||||
|
||||
|
||||
def _client(auth: RefreshingBearerAuth | None, handler: Any) -> httpx.AsyncClient:
|
||||
built = mcp_client._build_server(_config(), auth)
|
||||
params = cast("Any", built.server).params
|
||||
assert params["headers"] == ({} if auth else {"Authorization": "Bearer old"})
|
||||
client = cast("httpx.AsyncClient", params["httpx_client_factory"](headers=params["headers"]))
|
||||
client._transport = httpx.MockTransport(handler)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_retries_once_and_persists_across_server_builds() -> None:
|
||||
refresh = AsyncMock(return_value="new")
|
||||
session = mcp_session.SupervisedMcpSession(_config(), refresh_token=refresh)
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.headers["Authorization"])
|
||||
return httpx.Response(200 if seen[-1] == "Bearer new" else 401)
|
||||
|
||||
for _ in range(2):
|
||||
async with _client(session._bearer_auth, handler) as client:
|
||||
response = await client.post("https://example.test/mcp", content=b"payload")
|
||||
assert response.status_code == 200
|
||||
assert seen == ["Bearer old", "Bearer new", "Bearer new"]
|
||||
refresh.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("replacement", ["new", None, "old", "raises"])
|
||||
async def test_concurrent_unauthorized_requests_share_refresh(replacement: str | None) -> None:
|
||||
refresh = AsyncMock(return_value=replacement)
|
||||
if replacement == "raises":
|
||||
refresh.side_effect = ValueError("refresh unavailable")
|
||||
auth = RefreshingBearerAuth("old", refresh)
|
||||
both_sent = asyncio.Event()
|
||||
old_requests = 0
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal old_requests
|
||||
if request.headers["Authorization"] == "Bearer old":
|
||||
old_requests += 1
|
||||
if old_requests == 5:
|
||||
both_sent.set()
|
||||
await both_sent.wait()
|
||||
return httpx.Response(401)
|
||||
return httpx.Response(200)
|
||||
|
||||
async with _client(auth, handler) as client:
|
||||
responses = await asyncio.wait_for(
|
||||
asyncio.gather(*(client.get("https://example.test/mcp") for _ in range(5))),
|
||||
timeout=2,
|
||||
)
|
||||
assert [response.status_code for response in responses] == [
|
||||
200 if replacement == "new" else 401
|
||||
] * 5
|
||||
assert old_requests == 5
|
||||
refresh.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("replacement", [None, "", "old", "new", "raises", "disabled"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrecovered_401_still_retires_connection(
|
||||
replacement: str | None, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
refresh = AsyncMock(return_value=replacement)
|
||||
if replacement == "raises":
|
||||
refresh.side_effect = ValueError("private-token-value")
|
||||
auth = None if replacement == "disabled" else RefreshingBearerAuth("old", refresh)
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.headers["Authorization"])
|
||||
return httpx.Response(401)
|
||||
|
||||
async with _client(auth, handler) as client:
|
||||
server = AsyncMock()
|
||||
|
||||
async def call_tool(*_args: Any, **_kwargs: Any) -> None:
|
||||
response = await client.get("https://example.test/mcp")
|
||||
response.raise_for_status()
|
||||
|
||||
server.call_tool = call_tool
|
||||
session = mcp_session.SupervisedMcpSession.adopt(server, name="refresh")
|
||||
result = await session.dispatch("read", {}, label="refresh_read")
|
||||
assert result["success"] is False
|
||||
assert session.is_dead
|
||||
await session.aclose()
|
||||
assert seen == (["Bearer old", "Bearer new"] if replacement == "new" else ["Bearer old"])
|
||||
assert refresh.await_count == (0 if replacement == "disabled" else 1)
|
||||
assert "private-token-value" not in caplog.text
|
||||
if replacement == "raises":
|
||||
assert "ValueError" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_401_does_not_refresh() -> None:
|
||||
refresh = AsyncMock(return_value="new")
|
||||
auth = RefreshingBearerAuth("old", refresh)
|
||||
async with _client(auth, lambda _: httpx.Response(403)) as client:
|
||||
assert (await client.get("https://example.test/mcp")).status_code == 403
|
||||
refresh.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_passes_refresher_to_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
refresh = AsyncMock(return_value="new")
|
||||
registry = McpRegistry()
|
||||
entry = registry.register(McpConnectionRequest(_config(), refresh_token=refresh))
|
||||
server = AsyncMock()
|
||||
captured: list[RefreshingBearerAuth] = []
|
||||
|
||||
def build(_config: McpConnectionConfig, auth: RefreshingBearerAuth) -> Any:
|
||||
captured.append(auth)
|
||||
return mcp_client.BuiltMcpServer(server, None)
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_build_server", build)
|
||||
session = await entry.ensure_connected()
|
||||
assert entry.refresh_token is refresh
|
||||
assert captured == [session._bearer_auth]
|
||||
await session._open()
|
||||
assert captured == [session._bearer_auth, session._bearer_auth]
|
||||
await session.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refreshed_token_can_refresh_again_after_expiry() -> None:
|
||||
refresh = AsyncMock(side_effect=["new", "newer"])
|
||||
auth = RefreshingBearerAuth("old", refresh)
|
||||
valid = "new"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200 if request.headers["Authorization"] == f"Bearer {valid}" else 401)
|
||||
|
||||
async with _client(auth, handler) as client:
|
||||
assert (await client.get("https://example.test/mcp")).status_code == 200
|
||||
valid = "newer"
|
||||
assert (await client.get("https://example.test/mcp")).status_code == 200
|
||||
assert refresh.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_refresher", [True, False])
|
||||
@pytest.mark.asyncio
|
||||
async def test_eager_attachment_preserves_bearer_refresh(
|
||||
monkeypatch: pytest.MonkeyPatch, with_refresher: bool
|
||||
) -> None:
|
||||
refresh = AsyncMock(return_value="new")
|
||||
original_build = mcp_client._build_server
|
||||
valid = "old"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200 if request.headers["Authorization"] == f"Bearer {valid}" else 401)
|
||||
|
||||
def build(config: McpConnectionConfig, auth: RefreshingBearerAuth | None = None) -> Any:
|
||||
built = original_build(config, auth)
|
||||
params = cast("Any", built.server).params
|
||||
client = params["httpx_client_factory"](headers=params["headers"])
|
||||
client._transport = httpx.MockTransport(handler)
|
||||
server = AsyncMock()
|
||||
server.use_structured_content = False
|
||||
server.list_tools.return_value = [Tool(name="read", inputSchema={"type": "object"})]
|
||||
server.cleanup.side_effect = client.aclose
|
||||
|
||||
async def call_tool(*_args: Any, **_kwargs: Any) -> CallToolResult:
|
||||
response = await client.post("https://example.test/mcp")
|
||||
response.raise_for_status()
|
||||
return CallToolResult(content=[TextContent(type="text", text="ok")])
|
||||
|
||||
server.call_tool = call_tool
|
||||
return mcp_client.BuiltMcpServer(server, built.recorder)
|
||||
|
||||
monkeypatch.setattr(mcp_client, "_build_server", build)
|
||||
registry = McpRegistry()
|
||||
try:
|
||||
connections = await mcp_client.attach_mcp_requests(
|
||||
[McpConnectionRequest(_config(), refresh_token=refresh if with_refresher else None)],
|
||||
registry,
|
||||
)
|
||||
assert len(connections) == 1
|
||||
entry = registry.get("refresh")
|
||||
assert entry is not None
|
||||
assert entry.refresh_token is (refresh if with_refresher else None)
|
||||
session = await entry.ensure_connected()
|
||||
assert await session.dispatch("read", {}, label="refresh_read") == {
|
||||
"type": "text",
|
||||
"text": "ok",
|
||||
}
|
||||
valid = "new"
|
||||
result = await session.dispatch("read", {}, label="refresh_read")
|
||||
if with_refresher:
|
||||
assert result == {"type": "text", "text": "ok"}
|
||||
assert not session.is_dead
|
||||
refresh.assert_awaited_once()
|
||||
else:
|
||||
assert result["success"] is False
|
||||
assert session.is_dead
|
||||
finally:
|
||||
await registry.close()
|
||||
@@ -111,6 +111,15 @@ async def test_retry_after_parses_seconds_and_http_date() -> None:
|
||||
assert 0 <= retry_after <= 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_response_clears_previous_auth_failure() -> None:
|
||||
recorder = HttpStatusRecorder()
|
||||
await recorder(_http_error(401).response)
|
||||
request = httpx.Request("POST", "https://provider.example/tools")
|
||||
await recorder(httpx.Response(200, request=request))
|
||||
assert recorder.take() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recorder_only_keeps_non_sensitive_request_metadata() -> None:
|
||||
recorder = HttpStatusRecorder()
|
||||
|
||||
Reference in new issue
Block a user