Skip to content
Open
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
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,19 @@ openant set-api-key <your-key>

**The key must have access to the Claude Opus 4.6 model.** Get a key at [console.anthropic.com](https://console.anthropic.com/settings/keys).

If HTTPS traffic to Anthropic is intercepted (for example by Zscaler), export your corporate root CA in PEM form -- the same variable many tools use with Node:

```bash
export NODE_EXTRA_CA_CERTS=/path/to/your-corporate-root-ca.pem
```

OpenAnt's Python runtime loads that bundle for **Anthropic Python SDK** API calls so TLS verification succeeds behind the proxy. When this variable is set, Python 3.13+'s strict X.509 validation is relaxed to match Node's behavior, so Zscaler-class CAs (whose Basic Constraints extension is sometimes non-critical) validate -- a deliberate trade toward compatibility with typical corporate intercept roots and Node-like trust behavior.

### Python runtime

OpenAnt's parsing, enhancement, analysis, and reporting code is Python 3.11+. The Go CLI picks an interpreter in this order:

1. `OPENANT_PYTHON` env var (set this to pin a specific interpreter e.g. `OPENANT_PYTHON=python3.11`).
1. `OPENANT_PYTHON` env var (set this to pin a specific interpreter -- e.g. `OPENANT_PYTHON=python3.11`).
2. Managed venv at `~/.openant/venv/` (auto-created on first use). The CLI uses `bin/python` on Linux/macOS and `Scripts\python.exe` on Windows.
3. `python3` / `python` on `PATH`.

Expand All @@ -84,27 +92,27 @@ If none yield Python 3.11+, the command exits with an error pointing at [python.

OpenAnt creates two directories:

- **`~/.config/openant/`** CLI configuration (`config.json`). Stores your API key, active project, and preferences. File permissions are restricted to `0600`.
- **`~/.openant/`** Project data. Each initialized project gets a workspace under `~/.openant/projects/<org>/<repo>/` containing `project.json` and a `scans/` directory with per-commit outputs.
- **`~/.config/openant/`** - CLI configuration (`config.json`). Stores your API key, active project, and preferences. File permissions are restricted to `0600`.
- **`~/.openant/`** - Project data. Each initialized project gets a workspace under `~/.openant/projects/<org>/<repo>/` containing `project.json` and a `scans/` directory with per-commit outputs.

## Analyzing a project

### 1. Initialize

Point OpenAnt at a repository. The `-l` flag (language) is required use `go` or `python`.
Point OpenAnt at a repository. The `-l` flag (language) is required -- use `go` or `python`.

```bash
# Remote clones the repo
# Remote -- clones the repo
openant init <repo-url> -l go

# Remote pin to a specific commit
# Remote -- pin to a specific commit
openant init <repo-url> -l go --commit <sha>

# Local references the directory in-place
# Local -- references the directory in-place
openant init <path-to-repo> -l go --name <org/repo>
```

This creates a project workspace and sets it as the active project. All subsequent commands operate on the active project automatically no path arguments needed.
This creates a project workspace and sets it as the active project. All subsequent commands operate on the active project automatically -- no path arguments needed.

### 2. Run the pipeline

Expand Down
5 changes: 3 additions & 2 deletions libs/openant-core/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@
from pathlib import Path
from typing import Any

from anthropic import Anthropic
from dotenv import load_dotenv
from utilities.file_io import open_utf8, read_json, write_json

from utilities.anthropic_http import create_anthropic_client

# Load environment variables
load_dotenv()

Expand Down Expand Up @@ -509,7 +510,7 @@ def generate_application_context(

# Call LLM
print(f"Generating context with {model}...", file=sys.stderr)
client = Anthropic()
client = create_anthropic_client()
response = client.messages.create(
model=model,
max_tokens=2000,
Expand Down
29 changes: 25 additions & 4 deletions libs/openant-core/core/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@
HAS_APP_CONTEXT = False


def _format_exception_chain(exc: BaseException, limit: int = 500) -> str:
"""Readable error text; Anthropic often raises APIConnectionError with a hidden __cause__."""
parts: list[str] = []
cur: BaseException | None = exc
seen: set[str] = set()
depth = 0
while cur is not None and depth < 6:
msg = (str(cur) or type(cur).__name__).strip()
if msg and msg not in seen:
parts.append(msg)
seen.add(msg)
cur = cur.__cause__ or cur.__context__
depth += 1
text = " | ".join(parts) if parts else type(exc).__name__
if len(text) > limit:
return text[: limit - 3] + "..."
return text


def scan_repository(
repo_path: str,
output_dir: str,
Expand Down Expand Up @@ -620,8 +639,9 @@ def _step_label(name: str) -> str:
outputs["summary_path"] = summary_path
print(f" Summary: {summary_path}", file=sys.stderr)
except Exception as e:
print(f" WARNING: Summary report failed: {e}", file=sys.stderr)
ctx.errors.append(f"Summary report: {e}")
detail = _format_exception_chain(e)
print(f" WARNING: Summary report failed: {detail}", file=sys.stderr)
ctx.errors.append(f"Summary report: {detail}")

# Only generate disclosures if there are findings
if has_findings:
Expand All @@ -630,8 +650,9 @@ def _step_label(name: str) -> str:
outputs["disclosures_dir"] = disclosures_dir
print(f" Disclosures: {disclosures_dir}", file=sys.stderr)
except Exception as e:
print(f" WARNING: Disclosure docs failed: {e}", file=sys.stderr)
ctx.errors.append(f"Disclosure docs: {e}")
detail = _format_exception_chain(e)
print(f" WARNING: Disclosure docs failed: {detail}", file=sys.stderr)
ctx.errors.append(f"Disclosure docs: {detail}")

ctx.summary = {"formats_generated": list(outputs.keys())}
ctx.outputs = outputs
Expand Down
5 changes: 3 additions & 2 deletions libs/openant-core/generate_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@
import os
from datetime import datetime

import anthropic
from dotenv import load_dotenv
from utilities.file_io import read_json

from utilities.anthropic_http import create_anthropic_client

# Load environment variables from .env file
load_dotenv()

Expand Down Expand Up @@ -201,7 +202,7 @@ def generate_remediation_guidance(findings: list) -> str:
if not api_key:
raise ValueError("ANTHROPIC_API_KEY not found in environment")

client = anthropic.Anthropic(api_key=api_key)
client = create_anthropic_client(api_key=api_key)
response = client.messages.create(
model=REPORT_MODEL,
max_tokens=MAX_TOKENS,
Expand Down
4 changes: 2 additions & 2 deletions libs/openant-core/openant/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,9 +590,9 @@ def cmd_report_data(args):
and step reports — everything display-ready.
"""
import html as html_mod
import anthropic
from core.schemas import success, error
from core.step_report import step_context
from utilities.anthropic_http import create_anthropic_client
from utilities.llm_client import get_global_tracker

results_path = args.results
Expand Down Expand Up @@ -809,7 +809,7 @@ def cmd_report_data(args):
{findings_text}
"""
print("[Report] Generating remediation guidance (LLM)...", file=sys.stderr)
client = anthropic.Anthropic()
client = create_anthropic_client()
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
Expand Down
1 change: 1 addition & 0 deletions libs/openant-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"anthropic>=0.40.0",
"certifi>=2024.0.0",
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
"httpx>=0.24.0",
Expand Down
7 changes: 4 additions & 3 deletions libs/openant-core/report/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
import os
import re
import sys
import anthropic
from pathlib import Path
from dotenv import load_dotenv

from utilities.anthropic_http import create_anthropic_client

from .schema import validate_pipeline_output, ValidationError
from utilities.file_io import open_utf8, read_json

Expand Down Expand Up @@ -138,7 +139,7 @@ def generate_summary_report(pipeline_data: dict) -> tuple[str, dict]:
output_tokens, total_tokens, cost_usd.
"""
_check_api_key()
client = anthropic.Anthropic()
client = create_anthropic_client()

summary_data = _compact_for_summary(pipeline_data)
system_prompt = load_prompt("system")
Expand Down Expand Up @@ -201,7 +202,7 @@ def generate_disclosure(vulnerability_data: dict, product_name: str) -> tuple[st
(disclosure_text, usage_dict)
"""
_check_api_key()
client = anthropic.Anthropic()
client = create_anthropic_client()

system_prompt = load_prompt("system")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,11 @@ class _Usage:
@pytest.fixture
def patched_anthropic(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-key")
monkeypatch.setattr(generator.anthropic, "Anthropic", _FakeAnthropic)
monkeypatch.setattr(
generator,
"create_anthropic_client",
lambda **_kwargs: _FakeAnthropic(),
)


def test_generate_disclosure_output_has_real_code(patched_anthropic, pipeline_output):
Expand Down
89 changes: 89 additions & 0 deletions libs/openant-core/tests/test_anthropic_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Tests for utilities.anthropic_http (corporate TLS / NODE_EXTRA_CA_CERTS)."""

from __future__ import annotations

import os
import ssl
import tempfile
from typing import Any

import certifi
import pytest

from utilities.anthropic_http import _ssl_context_from_node_extra_ca_certs, create_anthropic_client

# Self-signed test CA (generated for this test module only; not a production trust anchor).
_TEST_CA_PEM = """\
-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIUb/55TWJ6Dq5Md9inc2PMh795YUkwDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRVGVzdCBDb3Jwb3JhdGUgQ0EwHhcNMjYwNTE1MDgzMzM4
WhcNMjcwNTE1MDgzMzM4WjAcMRowGAYDVQQDDBFUZXN0IENvcnBvcmF0ZSBDQTCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANT6B1N+UkVlx4u4MWA/NKmJ
XVHULjJiOqdgEAQpJ0a1LMWHBmabthkdxVtzXOvbvZMeV2MmoMtN0fUKRvY4TRvF
ktPKzyuopz7T2KdVF062wLsq6+T0essOJ5DcUJSsyWlfX9myfGYqAq+XA6lufh6E
S6nroeYxbXvmIt7yMOG/TTHFghXHNPvnwOk7iy1QpPJt5dFlvXuANsgfJ5nBDyLS
P3kU+/P/Q3TQ3Qe6aEwF5MdvVnGhDQ7waKj3HHdhAabwkAzzAxoHxkzbm+GT5y/G
rmd9i7xOdztpmNxQ1xE2kk/7qDqmgQumNDsUOPpGnUfRh4lbsXmp9YoqRB1EYlMC
AwEAAaNTMFEwHQYDVR0OBBYEFEm8vvB1B2V/Jsctb6P+eMxM4sbqMB8GA1UdIwQY
MBaAFEm8vvB1B2V/Jsctb6P+eMxM4sbqMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggEBAIbcE0Pcb7+azH9acQhsuRy3EEHaZYgBFmpfcnRqtgNHAfF3
hyevLhjCr+auiQ9NtAMEHeoRFGdqxqXG4BzqqIuoFrBc+cCwBxbeNVyWN0+6qqFi
ObYmZQu+wB3lPYdwlShNIGu+rRBNCsqymLtKIuLEqW1Rk1D5B4HiJCYDqBa0VXgY
IpGQR7qvDcgvH/AfYdfY3GSCMgAWDjAt28o6NV3lZC9gcfKmo3Vt1ID+W0DdE4TZ
8XqADeHTYAnJUQs4r81vbemLE8vbwuQR+/lSWyJ18jCAmoAjHl+VlHyaFdoFBCD+
bbc7He5nRua65XEUMIbTgpvT83E9KhHsJz+K96A=
-----END CERTIFICATE-----
"""


@pytest.fixture
def corporate_ca_pem_path() -> str:
with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as f:
f.write(_TEST_CA_PEM)
path = f.name
yield path
try:
os.unlink(path)
except OSError:
pass


def test_ssl_context_loads_extra_ca_into_trust_store(monkeypatch: pytest.MonkeyPatch, corporate_ca_pem_path: str):
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", corporate_ca_pem_path)
base = ssl.create_default_context(cafile=certifi.where()).cert_store_stats()["x509"]
ctx = _ssl_context_from_node_extra_ca_certs()
assert ctx is not None
assert ctx.cert_store_stats()["x509"] >= base + 1


def test_create_anthropic_client_injects_http_client_when_env_set(
monkeypatch: pytest.MonkeyPatch, corporate_ca_pem_path: str,
):
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", corporate_ca_pem_path)
captured: dict[str, Any] = {}

class _StubAnthropic:
def __init__(self, **kwargs: Any) -> None:
captured.update(kwargs)

import anthropic

monkeypatch.setattr(anthropic, "Anthropic", _StubAnthropic)
create_anthropic_client(api_key="test-key")
assert "http_client" in captured
assert captured["http_client"] is not None


def test_create_anthropic_client_skips_http_client_without_env(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("NODE_EXTRA_CA_CERTS", raising=False)
captured: dict[str, Any] = {}

class _StubAnthropic:
def __init__(self, **kwargs: Any) -> None:
captured.update(kwargs)

import anthropic

monkeypatch.setattr(anthropic, "Anthropic", _StubAnthropic)
create_anthropic_client(api_key="test-key")
assert "http_client" not in captured
3 changes: 2 additions & 1 deletion libs/openant-core/utilities/agentic_enhancer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import anthropic

from ..anthropic_http import create_anthropic_client
from ..llm_client import TokenTracker, get_global_tracker
from ..rate_limiter import get_rate_limiter
from .repository_index import RepositoryIndex
Expand Down Expand Up @@ -126,7 +127,7 @@ def __init__(
self.tool_executor = ToolExecutor(index)
self.entry_points = entry_points or set()
self.reachability = reachability
self.client = client or anthropic.Anthropic(max_retries=5)
self.client = client or create_anthropic_client(max_retries=5)

def analyze_unit(
self,
Expand Down
Loading