Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 31 additions & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ jobs:
python-version: ['3.10', '3.11', '3.12']

steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
Expand All @@ -29,53 +32,55 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install pytest-asyncio pytest-cov

- name: Run Pytest
- name: Run Pytest with coverage
run: |
python -m pytest tests/ -v --tb=short --junitxml=test-results.xml
pytest tests/ -v \
--cov=src/qwed_a2a \
--cov-report=xml \
--cov-report=term-missing \
--tb=short \
--junitxml=test-results.xml

- name: Upload coverage to Codecov
if: matrix.python-version == '3.11'
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
flags: unittests
name: qwed-a2a
fail_ci_if_error: false

- name: Upload Test Results to Mergify CI Insights
- name: Upload test results to Mergify CI Insights
uses: Mergifyio/gha-mergify-ci@668bd8fd12563b7816b51f69ad8a13652925ac55 # v6
if: ${{ always() && env.MERGIFY_TOKEN != '' }}
with:
token: ${{ secrets.MERGIFY_TOKEN }}
report_path: test-results.xml

- name: SonarCloud Scan
if: matrix.python-version == '3.11'
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

- name: Run Snyk to check for vulnerabilities
if: matrix.python-version == '3.11'
uses: snyk/actions/python@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high

lint:
permissions:
contents: read
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
with:
python-version: '3.11'

- name: Install linters
run: |
pip install black ruff
run: pip install black ruff

- name: Check formatting with Black
run: |
black --check src/ || echo "Formatting check failed (non-blocking)"
run: black --check --target-version py311 src/

- name: Lint with Ruff
run: |
ruff check src/ || echo "Lint check failed (non-blocking)"
run: ruff check src/
75 changes: 75 additions & 0 deletions .github/workflows/snyk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Snyk Security Scan — Dependency and SAST analysis
# Runs on PRs and weekly schedule

name: Snyk Security

on:
pull_request:
branches: [main]
schedule:
- cron: '0 9 * * 1' # weekly Monday 9 AM UTC

permissions:
contents: read
security-events: write

jobs:
snyk-dependencies:
name: Snyk Dependency Scan
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
with:
python-version: '3.11'

- name: Install Snyk CLI
run: npm install -g snyk

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run Snyk dependency scan
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: snyk test --org=${{ secrets.SNYK_ORG_ID }} --severity-threshold=high --sarif-file-output=snyk.sarif

- name: Upload dependency scan results to GitHub Security
uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3
if: always() && hashFiles('snyk.sarif') != ''
with:
sarif_file: snyk.sarif

snyk-code:
name: Snyk Code Analysis (SAST)
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
persist-credentials: false

- name: Install Snyk CLI
run: npm install -g snyk

- name: Run Snyk SAST scan
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
run: snyk code test --org=${{ secrets.SNYK_ORG_ID }} --sarif-file-output=snyk-code.sarif

- name: Upload SAST results to GitHub Security
uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3
if: always() && hashFiles('snyk-code.sarif') != ''
with:
sarif_file: snyk-code.sarif
49 changes: 49 additions & 0 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: SonarCloud

on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read
pull-requests: read

jobs:
sonarcloud:
name: SonarCloud
runs-on: ubuntu-latest
if: github.actor != 'dependabot[bot]'

steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
fetch-depth: 0 # full history for better analysis relevancy
persist-credentials: false

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Set up Python
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
with:
python-version: '3.11'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install pytest-asyncio pytest-cov

- name: Run tests with coverage
run: |
pytest tests/ -v \
--cov=src/qwed_a2a \
--cov-report=xml \
--cov-report=term-missing \
--tb=short

- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
1 change: 1 addition & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ sonar.sources=src
sonar.tests=tests
sonar.exclusions=**/__init__.py,**/*.md,**/*.txt
sonar.host.url=https://sonarcloud.io
sonar.python.coverage.reportPaths=coverage.xml
10 changes: 8 additions & 2 deletions src/qwed_a2a/interceptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@
engine_result = self._route_to_engine(message)
except Exception as exc:
logger.error("Verification engine error: %s", exc)
status = VerdictStatus.BLOCKED if self.config.block_on_error else VerdictStatus.FORWARDED
status = (
VerdictStatus.BLOCKED
if self.config.block_on_error
else VerdictStatus.FORWARDED
)
verdict = self._build_verdict(
trace_id=trace_id,
status=status,
Expand Down Expand Up @@ -212,7 +216,9 @@
quantity = item.get("quantity", 1)
computed_total += Decimal(str(amount)) * Decimal(str(quantity))

computed_total = computed_total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
computed_total = computed_total.quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
claimed_decimal = Decimal(str(claimed_total)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
Expand Down Expand Up @@ -286,17 +292,17 @@

# Compiled regex patterns for case-insensitive, whitespace-tolerant detection
_DANGEROUS_PATTERNS: Dict[str, re.Pattern] = {
"eval": re.compile(r"\beval\s*\(", re.IGNORECASE),

Check warning on line 295 in src/qwed_a2a/interceptor.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
"exec": re.compile(r"\bexec\s*\(", re.IGNORECASE),

Check warning on line 296 in src/qwed_a2a/interceptor.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
"subprocess": re.compile(

Check warning on line 297 in src/qwed_a2a/interceptor.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
r"\b(?:subprocess\s*\.|import\s+subprocess\b|from\s+subprocess\s+import\b)",
re.IGNORECASE,
),
"os.system": re.compile(r"\bos\.system\s*\(", re.IGNORECASE),

Check warning on line 301 in src/qwed_a2a/interceptor.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
"os.popen": re.compile(r"\bos\.popen\s*\(", re.IGNORECASE),

Check warning on line 302 in src/qwed_a2a/interceptor.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
"__import__": re.compile(r"__import__\s*\(", re.IGNORECASE),

Check warning on line 303 in src/qwed_a2a/interceptor.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
"compile": re.compile(r"\bcompile\s*\(", re.IGNORECASE),

Check warning on line 304 in src/qwed_a2a/interceptor.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
"importlib": re.compile(r"\bimportlib\s*\.", re.IGNORECASE),

Check warning on line 305 in src/qwed_a2a/interceptor.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
}

def _verify_code(self, payload: Dict[str, Any]) -> Dict[str, Any]:
Expand Down
8 changes: 4 additions & 4 deletions src/qwed_a2a/protocol/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def _load_trusted_agents(interceptor: A2AVerificationInterceptor) -> None:
logger.info("Trusted agent registered")
logger.info(
"Zero-trust boundary initialized with %d trusted agent(s)",
len([a.strip() for a in trusted_env.split(",") if a.strip()])
len([a.strip() for a in trusted_env.split(",") if a.strip()]),
)
else:
logger.warning(
Expand All @@ -49,18 +49,18 @@ def get_interceptor() -> A2AVerificationInterceptor:
if _interceptor is None:
_interceptor = A2AVerificationInterceptor()
_load_trusted_agents(_interceptor)

return _interceptor


def configure_interceptor(config: InterceptorConfig) -> None:
"""Reconfigure the interceptor at runtime (atomic swap)."""
global _interceptor
new_interceptor = A2AVerificationInterceptor(config=config)

# Reload trusted agents to maintain zero-trust allowlist
_load_trusted_agents(new_interceptor)

with _interceptor_lock:
_interceptor = new_interceptor

Expand Down
27 changes: 20 additions & 7 deletions src/qwed_a2a/protocol/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,16 @@ class AgentMessage(BaseModel):
"""

sender_agent_id: str = Field(
..., min_length=1, max_length=256, description="Unique identifier of the sending agent"
...,
min_length=1,
max_length=256,
description="Unique identifier of the sending agent",
)
receiver_agent_id: str = Field(
..., min_length=1, max_length=256, description="Unique identifier of the receiving agent"
...,
min_length=1,
max_length=256,
description="Unique identifier of the receiving agent",
)
payload_type: PayloadType = Field(
default=PayloadType.GENERAL, description="Classification of the payload content"
Expand All @@ -54,10 +60,12 @@ class AgentMessage(BaseModel):
description="ISO 8601 timestamp of message creation",
)
signature: Optional[str] = Field(
default=None, description="Optional JWT signature from the sender for tamper detection"
default=None,
description="Optional JWT signature from the sender for tamper detection",
)
metadata: Optional[Dict[str, Any]] = Field(
default=None, description="Optional metadata (correlation IDs, trace context, etc.)"
default=None,
description="Optional metadata (correlation IDs, trace context, etc.)",
)

@field_validator("sender_agent_id", "receiver_agent_id")
Expand All @@ -84,7 +92,8 @@ class VerificationVerdict(BaseModel):
..., description="Unique trace ID for this verification event"
)
attestation_jwt: Optional[str] = Field(
default=None, description="Signed JWT attestation proving the verification took place"
default=None,
description="Signed JWT attestation proving the verification took place",
)
engine_used: Optional[str] = Field(
default=None, description="Which verification engine handled the check"
Expand Down Expand Up @@ -113,10 +122,14 @@ class InterceptorConfig(BaseModel):
default=True, description="Route code payloads to AST security scanning"
)
block_on_error: bool = Field(
default=True, description="Block forwarding if verification encounters an internal error"
default=True,
description="Block forwarding if verification encounters an internal error",
)
max_payload_size_bytes: int = Field(
default=1_048_576, ge=1024, le=10_485_760, description="Maximum payload size (1MB default)"
default=1_048_576,
ge=1024,
le=10_485_760,
description="Maximum payload size (1MB default)",
)
trusted_agents: Optional[List[str]] = Field(
default=None, description="Allowlist of agent IDs that bypass verification"
Expand Down
1 change: 0 additions & 1 deletion src/qwed_a2a/security/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"""

import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
Expand Down
20 changes: 12 additions & 8 deletions src/qwed_a2a/security/trust_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
"""

import time
from collections import defaultdict
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Dict, Optional, Set, Tuple


Expand Down Expand Up @@ -98,15 +97,14 @@ def _evict_cold_buckets(self, now: float) -> None:
return # Only run eviction once per minute
self._last_eviction = now
cold_pairs = [
pair for pair, bucket in self._rate_limits.items()
pair
for pair, bucket in self._rate_limits.items()
if now - bucket.last_refill > self._eviction_ttl
]
for pair in cold_pairs:
del self._rate_limits[pair]

def evaluate(
self, sender_id: str, receiver_id: str
) -> Tuple[bool, Optional[str]]:
def evaluate(self, sender_id: str, receiver_id: str) -> Tuple[bool, Optional[str]]:
"""
Evaluate whether a sender->receiver communication is allowed.

Expand All @@ -127,8 +125,14 @@ def evaluate(

# Default policy check BEFORE rate-limit allocation (prevents map spray)
if not self.default_allow:
if sender_id not in self._trusted_agents and receiver_id not in self._trusted_agents:
return False, f"Neither sender '{sender_id}' nor receiver '{receiver_id}' is in the trust allowlist"
if (
sender_id not in self._trusted_agents
and receiver_id not in self._trusted_agents
):
return (
False,
f"Neither sender '{sender_id}' nor receiver '{receiver_id}' is in the trust allowlist",
)

# Token-bucket rate limiting (only reached by allowed pairs)
now = time.monotonic()
Expand Down
Loading
Loading