diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a203be7..224378b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +### 2.9.1 (Monday, August 10, 2026) +### Features/Bug Fixes +* fix(llm): add bounded connection retries +--- +### 2.9.0 (Monday, August 10, 2026) +### Features/Bug Fixes +* fix(e2): allow targeted environment credential reads +* fix: remediate nSpect High vulnerabilities +* fix(yara): require local destructive autonomy evidence +--- ### 2.8.2 (Friday, August 07, 2026) ### Features/Bug Fixes * fix(mcp): retry malformed TP4 responses diff --git a/README.md b/README.md index 2d4d64c1..6de01bb0 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ SkillSpector detects **68 vulnerability patterns** across 17 categories: | ID | Pattern | Severity | Description | |----|---------|----------|-------------| | E1 | External Transmission | MEDIUM | Sending data to external URLs | -| E2 | Env Variable Harvesting | HIGH | Collecting API keys and secrets | +| E2 | Env Variable Harvesting | HIGH | Enumerating, copying, or searching environment data to collect secrets | | E3 | File System Enumeration | MEDIUM | Scanning directories for sensitive files | | E4 | Context Leakage | HIGH | Transmitting conversation context externally | @@ -652,8 +652,8 @@ The top-level shape is (this example shows a full LLM-backed scan; with `--no-ll { "node": "semantic_security_discovery", "request_kind": "structured_output", - "provider": "anthropic", - "model": "claude-opus-4-6", + "provider": "nv_inference", + "model": "azure/anthropic/claude-opus-4-6", "model_source": "provider_response", "usage_source": "provider_response", "prompt_tokens": 1000, diff --git a/docs/INFERENCE_USAGE.md b/docs/INFERENCE_USAGE.md index b8d128b5..9dbfc487 100644 --- a/docs/INFERENCE_USAGE.md +++ b/docs/INFERENCE_USAGE.md @@ -27,8 +27,8 @@ Each successfully observed provider response contributes one entry to { "node": "semantic_security_discovery", "request_kind": "structured_output", - "provider": "anthropic", - "model": "claude-opus-4-6", + "provider": "nv_inference", + "model": "azure/anthropic/claude-opus-4-6", "model_source": "provider_response", "usage_source": "provider_response", "prompt_tokens": 1000, diff --git a/docs/release/skillspector-2.9.0.md b/docs/release/skillspector-2.9.0.md new file mode 100644 index 00000000..708e0d6b --- /dev/null +++ b/docs/release/skillspector-2.9.0.md @@ -0,0 +1,51 @@ +# SkillSpector v2.9.0 + +Released: 2026-08-10 + +## Summary + +This release includes 3 public-facing change(s) since release/2.8.2. + +## Highlights + +- fix(e2): allow targeted environment credential reads +- fix: remediate nSpect High vulnerabilities +- fix(yara): require local destructive autonomy evidence + +## Added + +- None. + +## Changed + +- None. + +## Fixed + +- fix(e2): allow targeted environment credential reads +- fix: remediate nSpect High vulnerabilities +- fix(yara): require local destructive autonomy evidence + +## Security + +- None. + +## Breaking Changes and Migration + +- None. + +## Deprecations + +- None. + +## Validation + +- Auto-generated from public-safe commit subjects since release/2.8.2; no additional validation commands were recorded by the release driver. + +## Known Limitations + +- None. + +## References + +- `CHANGELOG.md` diff --git a/docs/release/skillspector-2.9.1.md b/docs/release/skillspector-2.9.1.md new file mode 100644 index 00000000..e4870429 --- /dev/null +++ b/docs/release/skillspector-2.9.1.md @@ -0,0 +1,49 @@ +# SkillSpector v2.9.1 + +Released: 2026-08-10 + +## Summary + +This patch release improves resilience to transient LLM-provider connection failures during analysis. It uses bounded retries and records clearer batch-failure reasons when retries cannot recover. + +## Highlights + +- Adds bounded retries for transient LLM provider connection failures while preserving per-batch failure reporting. + +## Added + +- The inspection ledger distinguishes malformed structured LLM responses from exhausted connection retries. + +## Changed + +- Supported OpenAI and Anthropic clients use a common bounded native retry budget, while other providers receive the same bounded fallback retry schedule when applicable. + +## Fixed + +- Transient LLM connection failures no longer terminate a batch before the configured retry budget is exhausted. + +## Security + +- None. + +## Breaking Changes and Migration + +- None. + +## Deprecations + +- None. + +## Validation + +- `uv run --locked --extra dev pytest tests/nodes/test_llm_analyzer_base.py tests/nodes/test_meta_analyzer.py` — passed. +- `GitLab main pipeline 61992428` — passed. +- `git diff --check release/2.9.0..68c7a026d4b2d574b63019ceacd8fe8d7caa35db` — passed. + +## Known Limitations + +- Retries are limited to transient LLM provider connection errors; other provider errors fail immediately. + +## References + +- `CHANGELOG.md` diff --git a/package.json b/package.json index a8a025fe..ef32eb18 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "extensions": ["./extensions/skillspector.ts"] }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-ai": ">=0.78.1", + "@earendil-works/pi-coding-agent": ">=0.78.1", "typebox": "*" } } diff --git a/pyproject.toml b/pyproject.toml index eb9e0b9d..7c7be63b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "skillspector" -version = "2.8.2" +version = "2.9.1" description = "SkillSpector: Security scanner for AI agent skills (Claude Code, Cursor, and similar). Scans skills for vulnerabilities, malicious patterns, and security risks before installation. Supports Git repos, URLs, zips, and local directories; runs static pattern checks and optional LLM semantic analysis; outputs terminal, JSON, and Markdown reports with risk scoring." readme = "README.md" license = "Apache-2.0" @@ -51,7 +51,7 @@ dependencies = [ [project.optional-dependencies] mcp = [ - "mcp>=1.2.0", + "mcp>=1.29.0,<2.0.0", ] dev = [ "skillspector[mcp]", diff --git a/src/skillspector/inspection_ledger.py b/src/skillspector/inspection_ledger.py index 7beb3fe6..a0c286b8 100644 --- a/src/skillspector/inspection_ledger.py +++ b/src/skillspector/inspection_ledger.py @@ -48,6 +48,8 @@ class LedgerReason(StrEnum): EVAL_DATASET = "eval_dataset" SYNTAX_ERROR = "syntax_error" LLM_BATCH_FAILED = "llm_batch_failed" + LLM_STRUCTURED_RESPONSE_INVALID = "llm_structured_response_invalid" + LLM_CONNECTION_RETRIES_EXHAUSTED = "llm_connection_retries_exhausted" ANALYZER_RUNTIME_ERROR = "analyzer_runtime_error" UNACCOUNTED_WORK = "unaccounted_work" FINDING_ACCOUNTING_ERROR = "finding_accounting_error" @@ -75,6 +77,10 @@ class LedgerReason(StrEnum): ), LedgerReason.SYNTAX_ERROR: "Python source could not be parsed.", LedgerReason.LLM_BATCH_FAILED: "LLM analysis failed for this file range.", + LedgerReason.LLM_STRUCTURED_RESPONSE_INVALID: ( + "LLM returned a malformed structured response after retry." + ), + LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED: ("LLM connection failed after bounded retries."), LedgerReason.ANALYZER_RUNTIME_ERROR: ("Analyzer failed after beginning applicable work."), LedgerReason.UNACCOUNTED_WORK: ("Planned inspection work has no unique terminal outcome."), LedgerReason.FINDING_ACCOUNTING_ERROR: ( diff --git a/src/skillspector/llm_analyzer_base.py b/src/skillspector/llm_analyzer_base.py index 0e46e622..ec419e32 100644 --- a/src/skillspector/llm_analyzer_base.py +++ b/src/skillspector/llm_analyzer_base.py @@ -29,11 +29,14 @@ import asyncio import os +import time from collections import defaultdict from dataclasses import dataclass, field from typing import Any, Literal, cast +from langchain_anthropic import ChatAnthropic from langchain_core.messages import BaseMessage +from langchain_openai import ChatOpenAI from pydantic import BaseModel, Field, ValidationError, field_validator from skillspector.inspection_ledger import ( @@ -60,12 +63,33 @@ DEFAULT_MAX_LLM_CONCURRENCY = 10 STRUCTURED_RESPONSE_MAX_ATTEMPTS = 2 +API_CONNECTION_MAX_RETRIES = 3 +API_CONNECTION_RETRY_DELAYS_SECONDS = (0.5, 1.0, 2.0) +LLM_BATCH_MAX_ATTEMPTS = STRUCTURED_RESPONSE_MAX_ATTEMPTS + API_CONNECTION_MAX_RETRIES class _StructuredResponseValidationError(Exception): """Signal that provider output failed structured-response validation.""" +def _is_retryable_api_connection_error(exc: BaseException) -> bool: + """Return whether *exc* is the narrowly supported transient provider failure.""" + return type(exc).__name__ == "APIConnectionError" + + +def _uses_native_connection_retries(chat_model: object) -> bool: + """Set the common native retry budget and report whether it is available.""" + if isinstance(chat_model, ChatOpenAI): + for client in (chat_model.root_client, chat_model.root_async_client): + if client is not None: + client.max_retries = API_CONNECTION_MAX_RETRIES + return True + if isinstance(chat_model, ChatAnthropic): + chat_model.max_retries = API_CONNECTION_MAX_RETRIES + return True + return False + + def resolve_max_concurrency() -> int: """Resolve the LLM fan-out concurrency from ``SKILLSPECTOR_MAX_LLM_CONCURRENCY``. @@ -204,6 +228,7 @@ class BatchFailure: batch: Batch error_class: str + reason: LedgerReason = LedgerReason.LLM_BATCH_FAILED @dataclass @@ -298,7 +323,7 @@ def ledger_events_for_batches( path=path, start_line=start_line, end_line=end_line, - reason=LedgerReason.LLM_BATCH_FAILED, + reason=failure.reason, error_class=failure.error_class, ) ) @@ -458,6 +483,7 @@ def __init__(self, base_prompt: str, model: str, *, node: str = "llm_analyzer"): self.model = model self._input_budget = get_max_input_tokens(model) self._llm = get_chat_model(model=model) + self._uses_native_connection_retries = _uses_native_connection_retries(self._llm) self._structured_llm = ( self._llm.with_structured_output(self.response_schema) if self.response_schema else None ) @@ -585,6 +611,45 @@ def _invoke_batch(self, batch: Batch, prompt: str) -> tuple[Batch, list]: logger.debug("LLM response for %s", batch.file_label) return batch, self.parse_response(response, batch) + def _invoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[Batch, list]: + """Run one batch with bounded retries for malformed output and connection failures.""" + structured_retries = 0 + connection_retries = 0 + for attempt in range(1, LLM_BATCH_MAX_ATTEMPTS + 1): + try: + return self._invoke_batch(batch, prompt) + except _StructuredResponseValidationError: + if ( + structured_retries >= STRUCTURED_RESPONSE_MAX_ATTEMPTS - 1 + or attempt == LLM_BATCH_MAX_ATTEMPTS + ): + raise + structured_retries += 1 + logger.warning( + "LLM structured response validation failed for %s; retrying once", + batch.file_label, + ) + except Exception as exc: + if ( + not _is_retryable_api_connection_error(exc) + or self._uses_native_connection_retries + or connection_retries >= len(API_CONNECTION_RETRY_DELAYS_SECONDS) + or attempt == LLM_BATCH_MAX_ATTEMPTS + ): + raise + delay = API_CONNECTION_RETRY_DELAYS_SECONDS[connection_retries] + connection_retries += 1 + logger.warning( + "LLM connection failed for %s; retrying in %.2fs (%d/%d)", + batch.file_label, + delay, + connection_retries, + API_CONNECTION_MAX_RETRIES, + ) + time.sleep(delay) + + raise AssertionError("bounded retry loop must return or raise") + async def _ainvoke_batch(self, batch: Batch, prompt: str) -> tuple[Batch, list]: """Invoke and parse one batch asynchronously.""" logger.debug( @@ -607,6 +672,45 @@ async def _ainvoke_batch(self, batch: Batch, prompt: str) -> tuple[Batch, list]: logger.debug("LLM response for %s", batch.file_label) return batch, self.parse_response(response, batch) + async def _ainvoke_batch_with_retries(self, batch: Batch, prompt: str) -> tuple[Batch, list]: + """Asynchronously run one batch with bounded malformed-output and connection retries.""" + structured_retries = 0 + connection_retries = 0 + for attempt in range(1, LLM_BATCH_MAX_ATTEMPTS + 1): + try: + return await self._ainvoke_batch(batch, prompt) + except _StructuredResponseValidationError: + if ( + structured_retries >= STRUCTURED_RESPONSE_MAX_ATTEMPTS - 1 + or attempt == LLM_BATCH_MAX_ATTEMPTS + ): + raise + structured_retries += 1 + logger.warning( + "LLM structured response validation failed for %s; retrying once", + batch.file_label, + ) + except Exception as exc: + if ( + not _is_retryable_api_connection_error(exc) + or self._uses_native_connection_retries + or connection_retries >= len(API_CONNECTION_RETRY_DELAYS_SECONDS) + or attempt == LLM_BATCH_MAX_ATTEMPTS + ): + raise + delay = API_CONNECTION_RETRY_DELAYS_SECONDS[connection_retries] + connection_retries += 1 + logger.warning( + "LLM connection failed for %s; retrying in %.2fs (%d/%d)", + batch.file_label, + delay, + connection_retries, + API_CONNECTION_MAX_RETRIES, + ) + await asyncio.sleep(delay) + + raise AssertionError("bounded retry loop must return or raise") + def run_batches( self, batches: list[Batch], @@ -632,14 +736,7 @@ def run_batches_detailed( for batch in batches: try: prompt = self.build_prompt(batch, **kwargs) - try: - result = self._invoke_batch(batch, prompt) - except _StructuredResponseValidationError: - logger.warning( - "LLM structured response validation failed for %s; retrying once", - batch.file_label, - ) - result = self._invoke_batch(batch, prompt) + result = self._invoke_batch_with_retries(batch, prompt) outcome.successful.append(result) except _StructuredResponseValidationError: logger.warning( @@ -648,13 +745,27 @@ def run_batches_detailed( STRUCTURED_RESPONSE_MAX_ATTEMPTS, ) outcome.failures.append( - BatchFailure(batch=batch, error_class=ValidationError.__name__) + BatchFailure( + batch=batch, + error_class=ValidationError.__name__, + reason=LedgerReason.LLM_STRUCTURED_RESPONSE_INVALID, + ) ) except (ValueError, NotImplementedError): raise except Exception as exc: logger.warning("LLM batch failed for %s: %s", batch.file_label, exc) - outcome.failures.append(BatchFailure(batch=batch, error_class=type(exc).__name__)) + outcome.failures.append( + BatchFailure( + batch=batch, + error_class=type(exc).__name__, + reason=( + LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED + if _is_retryable_api_connection_error(exc) + else LedgerReason.LLM_BATCH_FAILED + ), + ) + ) return outcome async def arun_batches( @@ -675,12 +786,17 @@ async def arun_batches( so users on rate-limited providers can serialize the fan-out; an explicit argument still wins. - Failures are isolated per batch: a transient error (timeout, 429, - oversized-chunk 400, ...) costs only its own batch, which is logged - and omitted from the result, so one bad call cannot cancel the rest - of the fan-out. Malformed structured responses (Pydantic - ``ValidationError`` or CLI JSON parse failures) are retried once and - then isolated to their batch. + Failures are isolated per batch: a provider ``APIConnectionError`` + receives three bounded exponential-backoff retries (500ms, then 1s, + then 2s) when the chat model has no native retry support. OpenAI and + Anthropic chat models use their native three-retry policy instead; + native retry timing remains provider-managed. Unrecovered errors cost + only their own batch and are omitted from the result. + Malformed structured responses (Pydantic ``ValidationError`` or CLI + JSON parse failures) are retried once and then isolated to their batch. + A batch makes at most five outer chat-model invocations even when both + retry policies apply; native provider retries can make additional HTTP + requests within one invocation. Callers can detect partial results by comparing the returned batches against the submitted ones. Other ``ValueError`` instances and ``NotImplementedError`` signal misconfiguration rather than infra trouble @@ -709,14 +825,7 @@ async def arun_batches_detailed( async def _process(batch: Batch) -> tuple[Batch, list]: async with sem: prompt = self.build_prompt(batch, **kwargs) - try: - return await self._ainvoke_batch(batch, prompt) - except _StructuredResponseValidationError: - logger.warning( - "LLM structured response validation failed for %s; retrying once", - batch.file_label, - ) - return await self._ainvoke_batch(batch, prompt) + return await self._ainvoke_batch_with_retries(batch, prompt) results = await asyncio.gather(*[_process(b) for b in batches], return_exceptions=True) outcome = BatchExecutionResult() @@ -728,7 +837,11 @@ async def _process(batch: Batch) -> tuple[Batch, list]: STRUCTURED_RESPONSE_MAX_ATTEMPTS, ) outcome.failures.append( - BatchFailure(batch=batch, error_class=ValidationError.__name__) + BatchFailure( + batch=batch, + error_class=ValidationError.__name__, + reason=LedgerReason.LLM_STRUCTURED_RESPONSE_INVALID, + ) ) continue if isinstance(result, (ValueError, NotImplementedError)): @@ -736,7 +849,15 @@ async def _process(batch: Batch) -> tuple[Batch, list]: if isinstance(result, BaseException): logger.warning("LLM batch failed for %s: %s", batch.file_label, result) outcome.failures.append( - BatchFailure(batch=batch, error_class=type(result).__name__) + BatchFailure( + batch=batch, + error_class=type(result).__name__, + reason=( + LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED + if _is_retryable_api_connection_error(result) + else LedgerReason.LLM_BATCH_FAILED + ), + ) ) continue outcome.successful.append(result) diff --git a/src/skillspector/nodes/analyzers/pattern_defaults.py b/src/skillspector/nodes/analyzers/pattern_defaults.py index bb0a7f2b..697295be 100644 --- a/src/skillspector/nodes/analyzers/pattern_defaults.py +++ b/src/skillspector/nodes/analyzers/pattern_defaults.py @@ -51,7 +51,7 @@ class PatternCategory(StrEnum): "P4": "Subtle instructions detected that may alter agent decision-making or introduce hidden biases.", "P5": "This content may contain harmful instructions that could cause physical harm if followed. CRITICAL: Review carefully before use.", "E1": "Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.", - "E2": "Code accesses environment variables that may contain secrets (API keys, tokens). This is a common pattern for credential theft.", + "E2": "Code enumerates, copies, or searches environment variables for secrets. Bulk environment access can collect credentials unrelated to the skill's stated purpose.", "E3": "Code scans file system directories looking for sensitive files. This could be reconnaissance for credential theft.", "E4": "Code or instructions that leak agent conversation context to external services, potentially exposing sensitive user interactions.", "E5": "Data is uploaded to cloud storage (S3 / GCS / Azure Blob). This may be a legitimate backup or exfiltration to an external bucket. Manual review is recommended.", @@ -304,7 +304,7 @@ class PatternCategory(StrEnum): "P4": "Review content for implicit steering or bias. Ensure instructions are explicit and align with the skill's stated purpose.", "P5": "Remove all content that could lead to harmful outcomes. Add safety guardrails and human oversight for any high-risk operations.", "E1": "Verify the destination URL is trusted and necessary. Remove or replace with documented APIs. Ensure no secrets, tokens, or PII are transmitted.", - "E2": "Avoid reading sensitive env vars (API keys, tokens) unless strictly required. Use secrets managers or secure config. Never log or transmit credentials.", + "E2": "Read only explicitly required environment variables by name. Avoid enumerating or copying the full environment, and never log or transmit credentials to untrusted destinations.", "E3": "Remove unnecessary filesystem scanning. If file access is needed, use explicit, scoped paths. Avoid reading ~/.ssh, ~/.aws, or credential directories.", "E4": "Remove any code that sends prompts, responses, or session data externally. Preserve user privacy; never exfiltrate conversation content.", "E5": "Verify the destination bucket is trusted and owned by you. Never upload credentials, secrets, or workspace contents to external or unverified cloud storage.", diff --git a/src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py b/src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py index ff6f9322..e49ff42a 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py +++ b/src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py @@ -59,11 +59,6 @@ ] E2_PYTHON_FALLBACK_PATTERNS = [ (r"for\s+\w+\s*,\s*\w+\s+in\s+os\s*\.\s*environ\s*\.\s*items\s*\(\s*\)", 0.7), - ( - r"os\s*\.\s*environ\s*\[\s*['\"][^'\"]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[^'\"]*['\"]\s*\]", - 0.8, - ), - (r"os\s*\.\s*environ\s*\.\s*get\s*\([^)]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", 0.7), (r"os\s*\.\s*environ\s*\.\s*copy\s*\(\s*\)", 0.6), (r"dict\s*\(\s*os\s*\.\s*environ\s*\)", 0.6), (r"\{\s*\*\*\s*os\s*\.\s*environ\s*\}", 0.6), @@ -87,7 +82,6 @@ } _ENVIRONMENT_COLLECTION_CALLS = frozenset({"dict", "list", "tuple", "set", "frozenset"}) _ENVIRONMENT_COPY_CALLS = frozenset({"copy.copy", "copy.deepcopy"}) -_SENSITIVE_ENV_KEY_PATTERN = re.compile(r"(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE) E3_PATTERNS = [ (r"glob\s*\.\s*glob\s*\([^)]*(?:\.env|\.ssh|\.aws|\.config|credentials)", 0.8), (r"os\s*\.\s*walk\s*\([^)]*(?:home|~|/Users|/home)", 0.6), @@ -156,15 +150,6 @@ def _is_os_environ_reference(node: ast.expr, aliases: dict[str, str]) -> bool: return _resolve_expression_name(node, aliases) == "os.environ" -def _is_sensitive_environment_key(node: ast.expr) -> bool: - """Return whether a literal environment key looks credential-like.""" - return ( - isinstance(node, ast.Constant) - and isinstance(node.value, str) - and _SENSITIVE_ENV_KEY_PATTERN.search(node.value) is not None - ) - - def _has_direct_environ_argument(call: ast.Call, aliases: dict[str, str]) -> bool: """Return whether a call receives ``os.environ`` directly, not via a lookup.""" return any(_is_os_environ_reference(arg, aliases) for arg in call.args) or any( @@ -198,9 +183,10 @@ def _analyze_python_environment_reads( """Detect materializing or enumerating the complete ``os.environ`` mapping. A full mapping copy or enumeration is an environment-harvesting signal, unlike a - single-key lookup or passing ``os.environ`` through to a child process. AST parsing - makes the check insensitive to formatting and lets it resolve ``os`` / ``environ`` - import aliases. + targeted single-key lookup or passing ``os.environ`` through to a child process. + Credential flows to network and execution sinks remain covered by the behavioral + taint analyzer. AST parsing makes this check insensitive to formatting and lets it + resolve ``os`` / ``environ`` import aliases. ``None`` means the source could not be parsed, so callers can retain the regex fallback for malformed Python files. Standalone callers parse through the @@ -242,19 +228,6 @@ def emit(node: ast.AST, confidence: float) -> None: for ast_node in ast.walk(tree): if isinstance(ast_node, ast.Call): call_name = resolve_call_name(ast_node, aliases) - if call_name == "os.environ.get": - key = ( - ast_node.args[0] - if ast_node.args - else next( - (keyword.value for keyword in ast_node.keywords if keyword.arg == "key"), - None, - ) - ) - if key is not None and _is_sensitive_environment_key(key): - emit(ast_node, 0.7) - continue - if call_name is not None: method = call_name.rpartition(".")[2] if ( @@ -279,12 +252,6 @@ def emit(node: ast.AST, confidence: float) -> None: ): emit(ast_node, 0.6) - elif isinstance(ast_node, ast.Subscript): - if _is_os_environ_reference(ast_node.value, aliases) and _is_sensitive_environment_key( - ast_node.slice - ): - emit(ast_node, 0.8) - elif isinstance(ast_node, ast.Dict): if any( key is None and _is_os_environ_reference(value, aliases) diff --git a/src/skillspector/nodes/analyzers/static_yara.py b/src/skillspector/nodes/analyzers/static_yara.py index 91303758..0dc27632 100644 --- a/src/skillspector/nodes/analyzers/static_yara.py +++ b/src/skillspector/nodes/analyzers/static_yara.py @@ -62,6 +62,8 @@ _DEFAULT_RULE_ID = "YR4" _DEFAULT_SEVERITY = Severity.MEDIUM _DEFAULT_CONFIDENCE = 0.7 +_DESTRUCTIVE_AUTONOMY_RULE = "agent_skill_destructive_autonomous_actions" +_MAX_DESTRUCTIVE_AUTONOMY_LINE_DISTANCE = 3 # Module-level cache keyed by a content hash of all rule directories. _compiled_rules: yara.Rules | None = None @@ -205,6 +207,35 @@ def _extract_match_strings(match: yara.Match) -> tuple[int, str | None]: return first_offset, matched_text +def _has_local_destructive_autonomy_evidence(match: yara.Match, content: str) -> bool: + """Require destructive and autonomy evidence to occur in one local context. + + YARA string conditions are file-wide. Without this post-match check, a + scoped workspace reset near the start of a long skill combines with unrelated + prose such as "do not prompt per file" much later and becomes a false HIGH. + Root deletion remains blocking without autonomy evidence, matching the rule's + explicit condition. + """ + destructive_lines: list[int] = [] + autonomy_lines: list[int] = [] + for string_match in match.strings or []: + identifier = str(string_match.identifier) + for instance in string_match.instances or []: + line = get_line_number(content, instance.offset) + if identifier == "$destructive_rm_root": + return True + if identifier.startswith("$destructive_"): + destructive_lines.append(line) + elif identifier.startswith("$autonomy_"): + autonomy_lines.append(line) + + return any( + abs(destructive_line - autonomy_line) <= _MAX_DESTRUCTIVE_AUTONOMY_LINE_DISTANCE + for destructive_line in destructive_lines + for autonomy_line in autonomy_lines + ) + + def _parse_meta(match: yara.Match) -> tuple[str, Severity, float, str | None]: """Extract rule_id, severity, confidence, and description from a YARA match's meta.""" meta: dict[str, object] = match.meta or {} @@ -241,6 +272,16 @@ def _match_file(rules: yara.Rules, content: str, file_path: str) -> list[Analyze findings: list[AnalyzerFinding] = [] for match in matches: + if ( + match.rule == _DESTRUCTIVE_AUTONOMY_RULE + and not _has_local_destructive_autonomy_evidence(match, content) + ): + logger.debug( + "%s: ignored cross-context destructive/autonomy match in %s", + ANALYZER_ID, + file_path, + ) + continue rule_id, severity, confidence, description = _parse_meta(match) first_offset, matched_text = _extract_match_strings(match) diff --git a/src/skillspector/nodes/meta_analyzer.py b/src/skillspector/nodes/meta_analyzer.py index 70ffe1eb..e42a278c 100644 --- a/src/skillspector/nodes/meta_analyzer.py +++ b/src/skillspector/nodes/meta_analyzer.py @@ -564,7 +564,7 @@ def _meta_ledger_response( path=batch.file_path, start_line=batch.start_line if batch.end_line is not None else None, end_line=batch.end_line, - reason=LedgerReason.LLM_BATCH_FAILED, + reason=failure.reason, input_finding_ids=input_ids, emitted_finding_ids=input_ids, error_class=failure.error_class, diff --git a/tests/nodes/analyzers/test_static_patterns.py b/tests/nodes/analyzers/test_static_patterns.py index ff025395..8813f872 100644 --- a/tests/nodes/analyzers/test_static_patterns.py +++ b/tests/nodes/analyzers/test_static_patterns.py @@ -200,7 +200,7 @@ def test_e1_requests_post_produces_finding(self): assert e1[0].severity == "MEDIUM" def test_e2_env_harvesting_produces_finding(self): - """os.environ access for secrets yields E2, HIGH severity.""" + """Enumerating os.environ for secrets yields E2, HIGH severity.""" state = { "components": ["script.py"], "file_cache": { diff --git a/tests/nodes/analyzers/test_static_yara.py b/tests/nodes/analyzers/test_static_yara.py index f6379ab2..f56793a4 100644 --- a/tests/nodes/analyzers/test_static_yara.py +++ b/tests/nodes/analyzers/test_static_yara.py @@ -423,6 +423,28 @@ def test_destructive_autonomous_actions_rule(self): findings = _run_builtin(content, "setup.sh") assert _has_rule(findings, "agent_skill_destructive_autonomous_actions") + def test_destructive_action_does_not_combine_with_distant_autonomy_prose(self): + content = """# Target verification + +```bash +ssh target 'rm -rf ~/trt_profile_model && mkdir -p ~/trt_profile_model/results' +``` + +The intervening workflow validates artifacts and reports each result. +It does not delete any other path. + +## Cleanup review + +Present one retain-or-delete plan for the complete owned inventory. After the +user approves the plan, do not prompt per file. Delete only approved paths. +""" + findings = _run_builtin(content, "SKILL.md") + assert not _has_rule(findings, "agent_skill_destructive_autonomous_actions") + + def test_destructive_root_delete_remains_blocking_without_autonomy_phrase(self): + findings = _run_builtin("rm -rf /\n", "setup.sh") + assert _has_rule(findings, "agent_skill_destructive_autonomous_actions") + def test_credential_webhook_requires_collection_and_transmission(self): content = """ # Document how to rotate OPENAI_API_KEY. diff --git a/tests/nodes/test_llm_analyzer_base.py b/tests/nodes/test_llm_analyzer_base.py index fa4e652b..3c980f08 100644 --- a/tests/nodes/test_llm_analyzer_base.py +++ b/tests/nodes/test_llm_analyzer_base.py @@ -21,11 +21,14 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from langchain_anthropic import ChatAnthropic from langchain_core.messages import AIMessage +from langchain_openai import ChatOpenAI from pydantic import ValidationError from skillspector.inspection_ledger import LedgerReason, finalize_ledger from skillspector.llm_analyzer_base import ( + API_CONNECTION_MAX_RETRIES, DEFAULT_MAX_LLM_CONCURRENCY, Batch, BatchExecutionResult, @@ -203,6 +206,10 @@ def _structured_response_validation_error() -> ValidationError: return exc_info.value +class APIConnectionError(Exception): + """Test double matching the provider exception name used by the retry policy.""" + + class _RawTextAnalyzer(LLMAnalyzerBase): """Test analyzer for raw-string mode.""" @@ -457,6 +464,44 @@ async def test_arun_batches_uses_agent_cli_message_content(self) -> None: class TestRunBatches: MODEL = "nvidia/openai/gpt-oss-120b" + def test_sets_native_openai_connection_retry_budget(self) -> None: + chat_model = ChatOpenAI(model=self.MODEL, api_key="sk-test") + assert chat_model.root_client is not None + assert chat_model.root_async_client is not None + + with patch(MOCK_PATCH_TARGET, return_value=chat_model): + LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + + assert chat_model.root_client.max_retries == API_CONNECTION_MAX_RETRIES + assert chat_model.root_async_client.max_retries == API_CONNECTION_MAX_RETRIES + + def test_sets_native_anthropic_connection_retry_budget(self) -> None: + chat_model = ChatAnthropic(model="claude-sonnet-4-6", api_key="sk-test") + with patch(MOCK_PATCH_TARGET, return_value=chat_model): + LLMAnalyzerBase(base_prompt="test", model="claude-sonnet-4-6") + + assert chat_model.max_retries == API_CONNECTION_MAX_RETRIES + assert chat_model._client.max_retries == API_CONNECTION_MAX_RETRIES + assert chat_model._async_client.max_retries == API_CONNECTION_MAX_RETRIES + + @patch(MOCK_PATCH_TARGET) + @patch("skillspector.llm_analyzer_base.time.sleep") + def test_native_openai_connection_errors_are_not_retried_by_coordinator( + self, sleep: MagicMock, get_chat_model: MagicMock + ) -> None: + chat_model = ChatOpenAI(model=self.MODEL, api_key="sk-test") + get_chat_model.return_value = chat_model + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._invoke_batch = MagicMock(side_effect=APIConnectionError("provider detail")) + + outcome = analyzer.run_batches_detailed([Batch(file_path="a.py", content="code")]) + + assert analyzer._invoke_batch.call_count == 1 + sleep.assert_not_called() + assert [failure.reason for failure in outcome.failures] == [ + LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED + ] + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) def test_structured_validation_error_recovers_on_retry(self) -> None: analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) @@ -527,6 +572,80 @@ def test_structured_validation_error_isolated_after_retry(self) -> None: ] assert analyzer._structured_llm.invoke.call_count == 3 + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + @patch("skillspector.llm_analyzer_base.time.sleep") + def test_api_connection_error_recovers_with_bounded_backoff(self, sleep: MagicMock) -> None: + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._structured_llm.invoke = MagicMock( + side_effect=[APIConnectionError("provider detail"), LLMAnalysisResult(findings=[])] + ) + + outcome = analyzer.run_batches_detailed([Batch(file_path="a.py", content="code")]) + + assert len(outcome.successful) == 1 + assert outcome.failures == [] + assert analyzer._structured_llm.invoke.call_count == 2 + sleep.assert_called_once_with(0.5) + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + @patch("skillspector.llm_analyzer_base.time.sleep") + def test_api_connection_error_isolated_after_four_attempts(self, sleep: MagicMock) -> None: + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._structured_llm.invoke = MagicMock( + side_effect=[ + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + LLMAnalysisResult(findings=[]), + ] + ) + + outcome = analyzer.run_batches_detailed( + [ + Batch(file_path="failed.py", content="code"), + Batch(file_path="clean.py", content="code"), + ] + ) + + assert [batch.file_path for batch, _ in outcome.successful] == ["clean.py"] + assert [(failure.batch.file_path, failure.reason) for failure in outcome.failures] == [ + ("failed.py", LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED) + ] + assert analyzer._structured_llm.invoke.call_count == 5 + assert sleep.call_args_list == [ + ((0.5,), {}), + ((1.0,), {}), + ((2.0,), {}), + ] + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + @patch("skillspector.llm_analyzer_base.time.sleep") + def test_structured_error_then_connection_errors_keeps_both_retry_policies( + self, sleep: MagicMock + ) -> None: + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._structured_llm.invoke = MagicMock( + side_effect=[ + _structured_response_validation_error(), + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + LLMAnalysisResult(findings=[]), + ] + ) + + outcome = analyzer.run_batches_detailed([Batch(file_path="a.py", content="code")]) + + assert len(outcome.successful) == 1 + assert outcome.failures == [] + assert analyzer._structured_llm.invoke.call_count == 5 + assert sleep.call_args_list == [ + ((0.5,), {}), + ((1.0,), {}), + ((2.0,), {}), + ] + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) def test_value_error_still_propagates_without_retry(self) -> None: """Non-validation ValueError instances still signal misconfiguration.""" @@ -663,6 +782,103 @@ async def test_structured_validation_error_isolated_after_retry(self) -> None: ] assert analyzer._structured_llm.ainvoke.call_count == 3 + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + @patch("skillspector.llm_analyzer_base.asyncio.sleep", new_callable=AsyncMock) + async def test_api_connection_error_recovers_with_bounded_backoff( + self, sleep: AsyncMock + ) -> None: + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._structured_llm.ainvoke = AsyncMock( + side_effect=[APIConnectionError("provider detail"), LLMAnalysisResult(findings=[])] + ) + + outcome = await analyzer.arun_batches_detailed([Batch(file_path="a.py", content="code")]) + + assert len(outcome.successful) == 1 + assert outcome.failures == [] + assert analyzer._structured_llm.ainvoke.call_count == 2 + sleep.assert_awaited_once_with(0.5) + + @patch(MOCK_PATCH_TARGET) + @patch("skillspector.llm_analyzer_base.asyncio.sleep", new_callable=AsyncMock) + async def test_native_openai_connection_errors_are_not_retried_by_coordinator( + self, sleep: AsyncMock, get_chat_model: MagicMock + ) -> None: + chat_model = ChatOpenAI(model=self.MODEL, api_key="sk-test") + get_chat_model.return_value = chat_model + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._ainvoke_batch = AsyncMock(side_effect=APIConnectionError("provider detail")) + + outcome = await analyzer.arun_batches_detailed([Batch(file_path="a.py", content="code")]) + + assert analyzer._ainvoke_batch.call_count == 1 + sleep.assert_not_awaited() + assert [failure.reason for failure in outcome.failures] == [ + LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED + ] + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + @patch("skillspector.llm_analyzer_base.asyncio.sleep", new_callable=AsyncMock) + async def test_api_connection_error_isolated_after_four_attempts( + self, sleep: AsyncMock + ) -> None: + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._structured_llm.ainvoke = AsyncMock( + side_effect=[ + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + LLMAnalysisResult(findings=[]), + ] + ) + + outcome = await analyzer.arun_batches_detailed( + [ + Batch(file_path="failed.py", content="code"), + Batch(file_path="clean.py", content="code"), + ], + max_concurrency=1, + ) + + assert [batch.file_path for batch, _ in outcome.successful] == ["clean.py"] + assert [(failure.batch.file_path, failure.reason) for failure in outcome.failures] == [ + ("failed.py", LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED) + ] + assert analyzer._structured_llm.ainvoke.call_count == 5 + assert sleep.await_args_list == [ + ((0.5,), {}), + ((1.0,), {}), + ((2.0,), {}), + ] + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + @patch("skillspector.llm_analyzer_base.asyncio.sleep", new_callable=AsyncMock) + async def test_structured_error_then_connection_errors_keeps_both_retry_policies( + self, sleep: AsyncMock + ) -> None: + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._structured_llm.ainvoke = AsyncMock( + side_effect=[ + _structured_response_validation_error(), + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + APIConnectionError("provider detail"), + LLMAnalysisResult(findings=[]), + ] + ) + + outcome = await analyzer.arun_batches_detailed([Batch(file_path="a.py", content="code")]) + + assert len(outcome.successful) == 1 + assert outcome.failures == [] + assert analyzer._structured_llm.ainvoke.call_count == 5 + assert sleep.await_args_list == [ + ((0.5,), {}), + ((1.0,), {}), + ((2.0,), {}), + ] + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) async def test_custom_parser_validation_error_propagates_without_retry(self) -> None: analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) @@ -903,6 +1119,41 @@ def test_unchunked_batch_keeps_its_work_id_after_failure(self) -> None: assert failed_events[0]["start_line"] is None assert successful_events[0]["work_id"] == failed_events[0]["work_id"] + def test_safe_failure_reason_is_preserved_in_ledger_events(self) -> None: + batch = Batch(file_path="single.py", content="first line\nsecond line") + + events, status = ledger_events_for_batches( + "semantic_test", + BatchExecutionResult( + failures=[ + BatchFailure( + batch=batch, + error_class="ValidationError", + reason=LedgerReason.LLM_STRUCTURED_RESPONSE_INVALID, + ) + ] + ), + ) + + assert events[0]["reason_code"] == LedgerReason.LLM_STRUCTURED_RESPONSE_INVALID + assert events[0]["message"] == "LLM returned a malformed structured response after retry." + + completeness, _ = finalize_ledger( + { + "components": ["single.py"], + "findings": [], + "inspection_ledger": events, + "analyzer_status_events": [status], + } + ) + + assert completeness["ledger_exceptions"][0]["reason_code"] == ( + LedgerReason.LLM_STRUCTURED_RESPONSE_INVALID + ) + assert completeness["ledger_exceptions"][0]["message"] == ( + "LLM returned a malformed structured response after retry." + ) + def test_successful_unchunked_retry_has_one_terminal_outcome(self) -> None: """A retry does not create duplicate work IDs or fatal unaccounted work.""" batch = Batch(file_path="single.py", content="first line\nsecond line") diff --git a/tests/nodes/test_meta_analyzer.py b/tests/nodes/test_meta_analyzer.py index 95528b93..b87b57ee 100644 --- a/tests/nodes/test_meta_analyzer.py +++ b/tests/nodes/test_meta_analyzer.py @@ -24,7 +24,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -from skillspector.inspection_ledger import finalize_ledger +from skillspector.inspection_ledger import LedgerReason, finalize_ledger from skillspector.llm_analyzer_base import Batch, BatchExecutionResult, BatchFailure from skillspector.models import Finding from skillspector.nodes.meta_analyzer import ( @@ -202,6 +202,27 @@ def test_mixed_batches_distinguish_retained_and_filtered_findings(self) -> None: for event in events ] + def test_failed_batch_preserves_safe_failure_reason(self) -> None: + failed = _lineage_finding("failed", "failed.py", 3) + failed_batch = Batch(file_path="failed.py", content="failed", findings=[failed]) + + events, _ = _meta_ledger_response( + [failed_batch], + BatchExecutionResult( + failures=[ + BatchFailure( + batch=failed_batch, + error_class="APIConnectionError", + reason=LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED, + ) + ] + ), + [failed], + ) + + assert events[0]["reason_code"] == LedgerReason.LLM_CONNECTION_RETRIES_EXHAUSTED + assert events[0]["message"] == "LLM connection failed after bounded retries." + def test_overlapping_batches_do_not_reaccount_completed_finding(self) -> None: shared = _lineage_finding("shared", "complete.py", 1) failed_only = _lineage_finding("failed-only", "failed.py", 2) diff --git a/tests/unit/test_patterns.py b/tests/unit/test_patterns.py index d1b887aa..11dba52e 100644 --- a/tests/unit/test_patterns.py +++ b/tests/unit/test_patterns.py @@ -155,18 +155,31 @@ def test_e2_env_harvesting(self) -> None: [ 'os.environ.get("OPENAI_API_KEY")', 'os.environ.get(key="OPENAI_API_KEY")', + 'os.environ["NVCI_TOKEN"]', ], ) - def test_e2_env_get_secret(self, expression: str) -> None: - """Detection of specific secret access.""" + def test_e2_targeted_secret_read_is_not_harvesting(self, expression: str) -> None: + """Reading one explicitly named credential is not environment harvesting.""" content = f"import os\napi_key = {expression}\n" findings = data_exfiltration_module.analyze(content, "script.py", "python") - assert len(findings) >= 1 - assert any(f.rule_id == "E2" for f in findings) + + assert not any(f.rule_id == "E2" for f in findings) + + def test_e2_comment_describing_targeted_secret_read_is_not_harvesting(self) -> None: + """A comment that mentions os.environ.get cannot trigger the E2 fallback regex.""" + content = ( + "import os\n" + '# nvci-cli also reads os.environ.get("NVCI_TOKEN") from the environment\n' + 'token = os.environ.get("NVCI_TOKEN")\n' + ) + + findings = data_exfiltration_module.analyze(content, "script.py", "python") + + assert not any(f.rule_id == "E2" for f in findings) def test_e2_unparseable_python_uses_regex_fallback(self) -> None: - """Malformed Python preserves the pre-AST E2 regex coverage.""" - content = "import os\nsecret = os.environ.get('API_KEY')\ndef broken(\n" + """Malformed Python preserves bulk-environment E2 regex coverage.""" + content = "import os\nsecrets = os.environ.copy()\ndef broken(\n" findings = data_exfiltration_module.analyze(content, "script.py", "python") diff --git a/uv.lock b/uv.lock index 24a35b14..9887fd8c 100644 --- a/uv.lock +++ b/uv.lock @@ -1444,7 +1444,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.0" +version = "1.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1462,9 +1462,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, ] [[package]] @@ -2675,7 +2675,7 @@ wheels = [ [[package]] name = "skillspector" -version = "2.8.2" +version = "2.9.1" source = { editable = "." } dependencies = [ { name = "boto3" }, @@ -2726,7 +2726,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.0.10" }, { name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.14" }, { name = "langsmith", specifier = ">=0.7.30" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.29.0,<2.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0" }, { name = "openai", specifier = ">=2.25.0" }, { name = "packaging", specifier = ">=24.0" },