diff --git a/README.md b/README.md index aac8108..b04199c 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,77 @@ -# egressweave +# EgressWeave [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ContextualWisdomLab/EgressWeave) -**SSRF- and DNS-rebinding-safe outbound HTTP for Python.** - -`egressweave` validates an outbound URL against exact host-and-port -authority pairs and an HTTP-method allowlist, refuses any target that resolves -to a non-globally-routable address, and hands back a synchronous `httpx.Client` or -asynchronous `httpx.AsyncClient` whose every connection is *pinned* to the -validated addresses—rejecting authority drift and bounding outbound request -bodies, request-phase waits, and inbound identity-coded response bodies. - -It exists because the naive pattern—resolve, check the IP, then -`httpx.get(url)`—is unsafe. An attacker-controlled DNS answer can change between -the check and the connect (a TOCTOU / DNS-rebinding attack, CWE-350), while a -permissive URL, port, or method policy can reach unintended services (SSRF, -CWE-918), and even an allowlisted authority can exhaust resources with an -unbounded request producer, disabled timeout, or unbounded or compressed -response (CWE-400). - -## Product and architecture source of truth - -Start with [`docs/product/PRD.md`](docs/product/PRD.md) for buyer/product -requirements and [`docs/product/TRD.md`](docs/product/TRD.md) for the technical -contract. Root [`ARCHITECTURE.md`](ARCHITECTURE.md) remains the authoritative -protected-main implementation architecture. The product documentation labels -shipped, active-PR, accepted-target, and planned behavior separately so an -unmerged design is never presented as protected-main functionality. - -Architecture views and durable decisions are indexed in -[`docs/architecture/UML.md`](docs/architecture/UML.md), -[`docs/architecture/ERD.md`](docs/architecture/ERD.md), and -[`docs/adr/README.md`](docs/adr/README.md). The ERD explicitly records that the -core library owns no durable database rather than inventing persistence for -architecture completeness. - -## What it defends against - -- **SSRF (CWE-918):** rejects private, loopback, link-local, reserved, - multicast, unspecified, and otherwise non-global addresses; rejects embedded - credentials, query/fragment, plaintext `http` to remote hosts, IP-literal - hosts, backslash smuggling, and ASCII control characters. -- **Unexpected services on trusted hosts:** RFC 9110 defines an origin by its - scheme, host, and port. EgressWeave defaults to port 443 only and requires - explicit opt-in before an alternate TLS or local-development port is usable. -- **DNS rebinding / validate-then-connect TOCTOU (CWE-350):** resolves all - addresses up front, validates each, and pins them into a custom transport - that re-validates on every connect and refuses host/port drift. -- **Application-layer tunnelling:** a positive HTTP-method allowlist is enforced - at the transport boundary. Common API methods are enabled by default, unusual - methods require explicit opt-in, and `CONNECT` can never be authorized. -- **Unbounded or ambiguously framed requests (CWE-400, CWE-444):** both - transports reject a declared `Content-Length` beyond the finite policy budget - before pool dispatch and count actual synchronous or asynchronous stream - bytes. A valid declared length must equal the bytes consumed exactly; excess, - truncation, chunked overruns, and retry-based budget resets fail closed. The - first chunk crossing a policy or declared boundary is withheld and its source - is closed without replacing the generic policy error. -- **Disabled or excessive request timeouts (CWE-400):** both transports replace - missing or explicitly disabled HTTPX/HTTPCore connect, read, write, and pool - timeout values with immutable finite policy ceilings. Stricter non-negative - caller values are preserved, larger values are capped, and malformed timeout - metadata fails generically before connection-pool dispatch. -- **Unbounded response consumption (CWE-400):** both transports force - `Accept-Encoding: identity`, reject body-bearing content-coded responses and - unsafe declared lengths before returning a response, and count every - transfer-decoded identity body byte. Chunked, close-delimited, missing-length, - and dishonestly under-declared bodies cannot exceed the finite policy budget. -- **Bounded DNS resolution:** synchronous and asynchronous validation apply the - same finite positive `dns_timeout_seconds` deadline. Resolver workers are - concurrency-bounded and failures remain generic. -- **Exact egress allowlist:** only normalized `(hostname, port)` pairs explicitly - present in the policy are reachable; wildcards and accidental cross-pair - combinations are refused. -- Redirects are disabled and environment proxies ignored (`trust_env=False`), - so a `302` cannot bounce a request to an unvalidated host, and Unix sockets - are refused. -- **Fail-closed optional configuration:** an empty or absent base URL returns a - deny-all client rather than an unrestricted fallback transport. +**Provider-neutral outbound HTTP security for Python applications that need explicit egress authority instead of ambient network trust.** -## Publication status +EgressWeave turns an approved HTTPS destination and a small policy into a synchronous or asynchronous HTTPX client that keeps destination authority stable from URL validation through DNS resolution and connection establishment. It is designed for applications that must call external APIs without allowing SSRF, DNS rebinding, proxy inheritance, redirects, malformed framing, or unbounded network I/O to widen the approved network boundary. -Release automation and package acceptance establish that a commit is ready -to publish; they do not establish that an artifact is already available. -A bare `pip install egressweave` command is authoritative only after the -exact version appears on a verified PyPI project page with its wheel, source -distribution, and publish-attestation evidence. Until then, -install from a reviewed source checkout and preserve the repository's -hash-locked validation before promoting the package into another system. +It is a library, not a firewall or service mesh. Use it inside an application to make outbound HTTP decisions explicit, reviewable, and fail-closed; keep network-layer enforcement, tenant authorization, credentials, and business-level request policy in their owning systems. -## Install +## Why EgressWeave -After the target version is verified on PyPI: +| Need | EgressWeave contract | +| --- | --- | +| Call only approved remote services | Exact normalized `(hostname, port)` authority allowlists and positive HTTP-method policy | +| Resist DNS rebinding | Validate every resolved address, then connect only through the validated address set while preserving the approved hostname for TLS and HTTP authority | +| Avoid ambient routing surprises | Redirects are disabled, environment proxies are ignored, Unix-socket and caller-selected destination bypasses are refused | +| Bound network resource use | Finite DNS, timeout, request-body, response-body, target, header, pool and connection policies | +| Keep failures safe to expose | Policy denials use a stable public error contract instead of leaking resolver or transport internals | +| Support ordinary Python services | Synchronous and asynchronous clients with the same core security invariants | -```bash -pip install egressweave -``` +The protected implementation currently supports Python 3.10–3.14 and uses pinned `httpx`, `httpcore`, and `idna` runtime dependencies. + +## Publication status -From a reviewed local checkout before the first public release: +The package metadata version is `0.3.0`, but this repository currently has no GitHub release. Release automation and package acceptance can establish that source is ready to publish; they do not establish that an artifact is already available. + +A bare `pip install egressweave` command is authoritative only after the exact target version appears on a verified PyPI project page with its expected distributions and publication/provenance evidence. Until then, install from a reviewed source checkout and preserve the repository's hash-locked verification before promoting the package into another system. + +## Quickstart + +Install from a reviewed checkout before independently verified artifact publication: ```bash python -m pip install . ``` -## Quickstart - -Synchronous applications: +Create an exact egress policy and a synchronous client: ```python from egressweave import EgressPolicy, build_egress_sync_client -policy = EgressPolicy.from_hosts("api.openai.com, api.anthropic.com") - -normalized_url, client = build_egress_sync_client( - "https://api.openai.com/v1", policy=policy +policy = EgressPolicy.from_hosts("api.example.com") +base_url, client = build_egress_sync_client( + "https://api.example.com/v1", + policy=policy, ) + with client: - response = client.get(f"{normalized_url}/models") + response = client.get(f"{base_url}/status") ``` -Asynchronous applications: +Asynchronous applications use the same policy boundary: ```python from egressweave import EgressPolicy, build_egress_http_client -policy = EgressPolicy.from_hosts("api.openai.com, api.anthropic.com") - -normalized_url, client = await build_egress_http_client( - "https://api.openai.com/v1", policy=policy -) -async with client: - response = await client.get(f"{normalized_url}/models") -``` - -Narrow the method surface for each integration: - -```python -read_only_policy = EgressPolicy.from_hosts( +policy = EgressPolicy.from_hosts( "api.example.com", allowed_methods={"GET", "HEAD"}, ) -``` - -Authorize a non-standard HTTPS port only when the integration requires it: - -```python -alternate_port_policy = EgressPolicy.from_hosts( - "api.example.com", - allowed_ports={443, 8443}, +base_url, client = await build_egress_http_client( + "https://api.example.com/v1", + policy=policy, ) + +async with client: + response = await client.get(f"{base_url}/status") ``` -When several hosts use different ports, enumerate the exact authority pairs -instead of authorizing their Cartesian product: +When different hosts need different ports, enumerate the allowed authority pairs instead of granting their Cartesian product: ```python -split_service_policy = EgressPolicy.from_authorities( +policy = EgressPolicy.from_authorities( [ ("api.example.com", 443), ("admin.example.com", 8443), @@ -163,231 +79,154 @@ split_service_policy = EgressPolicy.from_authorities( ) ``` -Configure private trust or mutual TLS without sharing mutable SSL contexts: +## Security model -```python -from egressweave import TLSConfiguration +EgressWeave protects the **outbound transport decision**. Its guarded clients reject destinations and request metadata that would make the approved authority ambiguous or broader than the configured policy. -tls_configuration = TLSConfiguration( - ca_file="/etc/company/private-ca.pem", - client_certificate_file="/etc/company/client.pem", - client_private_key_file="/etc/company/client.key", -) -normalized_url, client = build_egress_sync_client( - "https://api.example.com", - policy=EgressPolicy.from_hosts("api.example.com"), - tls_configuration=tls_configuration, -) -``` +The protected implementation includes these control families: -Set integration-specific outbound and inbound body budgets when the 16 MiB -defaults are not appropriate: +- **SSRF resistance:** private, loopback, link-local, reserved, multicast, unspecified, IP-literal, credential-bearing, malformed, and unauthorized destinations fail closed. +- **DNS-rebinding resistance:** all accepted address candidates are validated before use and bound to the approved authority through the pinned transport. +- **Exact origin control:** hostname, port, scheme and HTTP method remain explicit; `CONNECT` is never authorized. +- **TLS identity preservation:** validated address pinning does not replace the approved hostname used for TLS server identity and HTTP authority. +- **Request bounds:** request target, headers, body framing, actual streamed bytes, declared length and per-phase timeouts are finite and checked before or during dispatch. +- **Response bounds:** response headers, declared length, content coding and actual streamed bytes are bounded; guarded clients request identity encoding to avoid unbounded decompression through the normal path. +- **Connection-pool bounds:** pool fanout and idle retention are explicitly bounded through `EgressConnectionPoolPolicy`, while acquisition waits are bounded through `EgressTimeoutPolicy`. +- **Stable denial semantics:** rejected operations raise the public policy error rather than exposing dependency-private failure details as an oracle. +- **Bounded decision evidence:** accepted decisions can be projected into versioned evidence without treating payloads, credentials, paths, or resolved IP addresses as routine audit output. +- **Deny-all optional configuration:** a missing or blank optional base URL produces a client that cannot perform network I/O instead of silently falling back to unrestricted HTTP. -```python -artifact_policy = EgressPolicy.from_hosts( - "artifacts.example.com", - max_request_bytes=8 * 1024 * 1024, - max_response_bytes=64 * 1024 * 1024, -) -``` +EgressWeave complements, rather than replaces, firewall/service-mesh egress policy, sandboxing, application authorization, OAuth/API-key scope, tenant policy, malware inspection, job-level cancellation and service-level operations. + +See [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md) for the detailed trust boundary and implementation evidence. -Set immutable per-phase timeout ceilings when an integration needs limits other -than the five-second defaults: +## Configure the boundary + +The high-level policy surface is intentionally small: ```python -from egressweave import EgressTimeoutPolicy +from egressweave import EgressPolicy, EgressTimeoutPolicy -timeout_policy = EgressTimeoutPolicy( +timeouts = EgressTimeoutPolicy( connect_timeout_seconds=2, read_timeout_seconds=10, write_timeout_seconds=5, pool_timeout_seconds=1, ) + policy = EgressPolicy.from_hosts( - "api.example.com", - request_timeout_policy=timeout_policy, + "artifacts.example.com", + allowed_methods={"GET", "HEAD", "PUT"}, + max_request_bytes=8 * 1024 * 1024, + max_response_bytes=64 * 1024 * 1024, + request_timeout_policy=timeouts, ) ``` -The default authority projection uses port `{443}`. `from_hosts(...)` remains -concise when several hosts share one port or one host intentionally exposes -several ports. Supplying several hosts and several ports is rejected as -ambiguous; use `from_authorities(...)` to enumerate the exact permitted pairs. -Hostnames use the same UTS #46 normalization as URL validation, and ports may be -integers or ASCII decimal strings between 1 and 65535. Empty port segments are -ignored for environment-variable ergonomics. Port zero, booleans, floats, -malformed text, and out-of-range values fail fast. The exact normalized pair is -checked before DNS resolution. - -The default method set is `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and -`OPTIONS`. Method names are validated and normalized at policy construction. -Less common non-tunnelling methods such as `PROPFIND` require explicit opt-in. -`CONNECT` is always rejected, including when present in configuration. - -The default request-body budget is 16 MiB. `max_request_bytes` accepts a -positive integer or an ASCII decimal string for environment-variable use. Zero, -negative, boolean, fractional, empty, signed, non-ASCII, or malformed values -fail at policy construction. A single declared `Content-Length` greater than -the budget is rejected before connection-pool dispatch. When a valid declared -length is present, the actual body must contain exactly that many bytes: excess -and truncated streams both fail closed. Chunked and missing-length streams are -still counted against the policy limit. The byte counter remains cumulative -across repeated iteration or retry of a replayable source, so re-consumption -does not grant another allowance. The first chunk crossing a policy or declared -boundary is not sent, the source stream is closed, and the generic -`EgressNotAllowedError` is raised without disclosing thresholds or byte counts. - -The default `EgressTimeoutPolicy` applies five-second ceilings independently to -connect, read, write, and pool-acquisition phases. HTTPX low-level request -metadata cannot disable a phase with `None` or extend it beyond the injected -ceiling. A finite non-negative value below the ceiling remains valid, including -zero for an immediate stricter timeout. Boolean, negative, non-finite, -non-numeric, unknown-key, and non-mapping metadata fails with the generic policy -error before HTTPCore dispatch. These phase limits bound inactivity rather than -a complete end-to-end wall-clock duration; applications should still impose job -cancellation, queue capacity, concurrency, tenant quota, and total-deadline -controls where required. - -The default response-body budget is 16 MiB. `max_response_bytes` accepts a -positive integer or an ASCII decimal string for environment-variable use. Zero, -negative, boolean, fractional, empty, signed, or malformed values fail at policy -construction. Pinned transports replace every caller compression preference -with `Accept-Encoding: identity`; a body-bearing response that still uses gzip, -deflate, Brotli, or another content coding is closed and rejected before HTTPX -can allocate decompressed output. Duplicate, malformed, or over-budget -`Content-Length` values fail before the response becomes caller-visible, and -every identity body stream is counted independently of framing metadata. On an -overrun, the underlying stream is closed and `EgressNotAllowedError` is raised. -Responses to `HEAD`, informational responses, `204`, and `304` remain bodyless -under RFC 9112 and do not treat representation metadata as transferred bytes. - -Both builders fail closed when the supplied base URL is `None`, empty, or only -whitespace: they return `(None, client)`, but that client rejects every request -with `EgressNotAllowedError` before network I/O. This lets applications preserve -optional configuration shapes without silently bypassing the egress policy. - -DNS resolution for both builders is bounded by `policy.dns_timeout_seconds`. -The value must be a finite positive number; invalid configuration is rejected -at policy construction rather than silently disabling the deadline. - -Hostname allowlist configuration is also validated when `EgressPolicy` is -constructed. Supply bare hostnames only. Wildcards, URLs, credentials, ports, -paths, IP literals or legacy numeric IP forms, and embedded whitespace/control -characters raise `ValueError` before request handling begins; non-string entries -raise `TypeError`. Empty host segments remain ignored so comma-separated -environment variables may contain trailing separators. - -Validate without building a client: +Private trust stores and mutual TLS are configured through immutable `TLSConfiguration` rather than a shared mutable SSL context: ```python -from egressweave import EgressPolicy, validate_egress_url, EgressNotAllowedError - -policy = EgressPolicy.from_hosts("api.openai.com") -try: - url = validate_egress_url("https://api.openai.com/v1", policy=policy) -except EgressNotAllowedError: - ... # generic, non-leaking rejection -``` +from egressweave import EgressPolicy, TLSConfiguration, build_egress_sync_client -Local development requires both the local-address escape hatch and the exact -service port. For an Ollama-style container: +tls = TLSConfiguration( + ca_file="/etc/company/private-ca.pem", + include_default_trust_store=False, + client_certificate_file="/etc/company/client.pem", + client_private_key_file="/etc/company/client.key", +) -```python -policy = EgressPolicy.from_hosts( - "ollama", - allow_local=True, - allowed_ports={11434}, +base_url, client = build_egress_sync_client( + "https://api.example.com", + policy=EgressPolicy.from_hosts("api.example.com"), + tls_configuration=tls, ) ``` -## API +Local development requires explicit opt-in to both local addressing and the exact service port; production callers should not inherit that exception accidentally. + +## Product boundary + +EgressWeave owns the reusable in-process policy, validation, TLS, connection-pool, pinned-transport, and bounded decision-evidence contracts. A host such as `naruon` owns provider configuration, credentials, tenancy, business authorization, persistence, audit retention, deployment, and the adapter that translates host settings into an EgressWeave policy. + +```text +Host application + │ approved base URL + explicit policy + ▼ +EgressWeave + ├─ URL / authority / method validation + ├─ bounded DNS resolution + address validation + ├─ TLS / pool / transport policy + ├─ bounded sync or async HTTPX client + └─ bounded decision-evidence projection + │ + ▼ +Approved remote service +``` + +The library does not own a durable database. It does not infer which provider or endpoint a tenant is entitled to call, and it does not treat a successful network connection as application-level authorization. + +## Public API | Symbol | Purpose | -|---|---| -| `EgressPolicy` | Injected exact `(hostname, port)` authority, HTTP-method, DNS-timeout, local-address, request-timeout, and finite request/response body resource policy; use `from_authorities(...)` when both host and port axes vary. | -| `EgressTimeoutPolicy` | Immutable finite connect, read, write, and pool-acquisition timeout ceilings enforced immediately before HTTPCore dispatch. | -| `TLSConfiguration` | Immutable provider-neutral TLS 1.3/TLS 1.2 compatibility, private trust, and optional mutual-TLS client identity settings. | -| `validate_egress_url` / `validate_egress_url_details` (+ `_async`) | Validate a URL and resolve pinnable addresses. | -| `build_egress_sync_client(url, *, policy)` | Validate + build a synchronous DNS-pinned `httpx.Client`; empty URLs produce a deny-all client and request bodies, phase waits, and response bodies are bounded. | -| `build_egress_http_client(url, *, policy)` | Validate + build an asynchronous DNS-pinned `httpx.AsyncClient`; empty URLs produce a deny-all client and request bodies, phase waits, and response bodies are bounded. | -| `build_pinned_https_client(validated, *, policy)` | Build a bounded synchronous client from an already-validated URL. | -| `build_pinned_https_async_client(validated, *, policy)` | Build a bounded asynchronous client from an already-validated URL. | -| `ValidatedEgressURL`, `EgressNotAllowedError` | Result type and typed failure (a `ValueError`). | - -## Compatibility note - -Exact authority-pair allowlisting, finite request/response body limits, -identity-only response coding, and finite request-phase timeout ceilings are -intentional pre-1.0 secure-default tightenings. Applications with several hosts -and several ports must migrate ambiguous `from_hosts(...)` configuration to -explicit `from_authorities(...)` pairs. Integrations that legitimately upload -or consume more than 16 MiB per message must set larger, still-finite -`max_request_bytes` or `max_response_bytes` values. Integrations needing longer -network inactivity windows must inject larger, still-finite -`EgressTimeoutPolicy` ceilings. Integrations that require compressed response -content need a separately reviewed client with a bounded streaming decoder; -EgressWeave does not silently accept compression. - -## One source, multi use (OSMU) - -`egressweave` is extracted, behaviour-preserving, from a production control -plane ([naruon](https://github.com/ContextualWisdomLab/naruon)), where it guards -every LLM-provider call. It is usable both as a standalone dependency and as a -git submodule. The original extraction replaced the app-specific settings -object with an injected `EgressPolicy`. - -## Autonomous maintenance - -Two hourly, credential-separated workflows keep the pull-request queue and the -product roadmap moving without bypassing normal governance: - -- at minute `07`, the repository calls the organization-owned review-fix and - merge schedulers to inspect feedback, recheck current-head evidence, and - update eligible branches; final merges remain operator-controlled; -- at minute `37`, a bounded OpenCode maintainer backed by - `NVIDIA_NIM_API_KEY` runs only when there are zero open pull requests and - implements one test-driven improvement. - -The product workflow uses two fresh runners. The model job has read-only GitHub -permissions, no direct network access, and can emit only a guard-checked patch. -A second credential-free job builds trusted dependencies before applying the -patch and executes modified source only inside an offline, non-root, -capability-free, read-only verifier container. It emits only a short-lived -digest-bound handoff; no repository-local job obtains write authority or -publishes the patch. CI, security scans, independent reviews, branch -protection, and the operator-controlled merge boundary remain authoritative. See -[`docs/hourly-autonomous-maintenance.md`](docs/hourly-autonomous-maintenance.md) -for the complete control and configuration contract. - -## Version compatibility - -EgressWeave's complete hosted quality lane covers Python 3.10–3.14; package -metadata retains Python 3.10 as the minimum supported runtime. - -The pinned transports use a few `httpx` / `httpcore` internals, so those -libraries are constrained to `httpx>=0.28,<0.29` and `httpcore>=1.0,<2.0` and -exercised by the test suite. Bumping either requires re-verifying both the -synchronous and asynchronous transports. - -## Research grounding - -See [`docs/research`](docs/research/README.md): OWASP SSRF Prevention and -positive scheme/port/destination allowlisting, secure defaults / fail securely, -CWE-918, CWE-350 (DNS rebinding / TOCTOU), CWE-400 (uncontrolled resource -consumption), CWE-444 (HTTP request interpretation differentials), RFC 9110 -(origin authority, content coding, and `CONNECT`), RFC 9112 (HTTP/1.1 message -framing and response body length), and RFC 8305 (Happy Eyeballs-style concurrent -connect across asynchronously pinned addresses). The exact authority decision is -specified in [`exact-authority-pairs.md`](docs/research/exact-authority-pairs.md), -outbound request limits in -[`request-body-resource-limits.md`](docs/research/request-body-resource-limits.md), -request-timeout ceilings in -[`request-timeout-boundaries.md`](docs/research/request-timeout-boundaries.md), -response limits in -[`response-body-resource-limits.md`](docs/research/response-body-resource-limits.md), -and enterprise TLS configuration in -[`tls-configuration.md`](docs/research/tls-configuration.md). +| --- | --- | +| `EgressPolicy` | Immutable destination, method, DNS and resource policy | +| `EgressTimeoutPolicy` | Finite connect/read/write/pool timeout ceilings, including pool-acquisition wait | +| `EgressConnectionPoolPolicy` | Finite total/keep-alive connection capacity and keep-alive expiry | +| `TLSConfiguration` | Immutable trust-store and optional mutual-TLS configuration | +| `validate_egress_url(...)` / `validate_egress_url_details(...)` | Synchronously validate a URL and its pinnable address candidates | +| `validate_egress_url_async(...)` / `validate_egress_url_details_async(...)` | Asynchronously validate a URL and its pinnable address candidates | +| `build_egress_sync_client(...)` | Build a synchronous guarded HTTPX client | +| `build_egress_http_client(...)` | Build an asynchronous guarded HTTPX client | +| `build_pinned_https_client(...)` / `build_pinned_https_async_client(...)` | Build from an already validated destination | +| `ValidatedEgressURL` | Integrity-bound validated destination state | +| `EgressDecisionEvidence` / `build_egress_decision_evidence(...)` | Produce bounded, versioned accepted-decision evidence | +| `get_decision_evidence_json_schema()` / `DECISION_EVIDENCE_SCHEMA_VERSION` | Expose the machine-readable decision-evidence contract | +| `EgressNotAllowedError` | Stable public policy-denial error | + +For exact arguments, invariants and pre-1.0 compatibility rules, use [`docs/product/API_CONTRACT.md`](docs/product/API_CONTRACT.md) rather than copying implementation details into a host integration. + +## Verification + +The repository requires 100% owned production statement and branch coverage and tests the package across Python 3.10, 3.11, 3.12, 3.13 and 3.14. CI also builds and verifies wheel/source distributions and smoke-tests the installed wheel outside the source tree. + +Run the same hash-locked quality toolchain used by CI: + +```bash +python -m pip install --require-hashes -r requirements-ci.txt +ruff check . +coverage run -m pytest -q +coverage report -m +python -m compileall -q src tests scripts +``` + +Release readiness and package-build evidence are not the same as publication evidence. Verify the exact released artifact and provenance before depending on a bare package-index install in production. + +## Documentation + +- [Product requirements](docs/product/PRD.md) +- [Technical requirements](docs/product/TRD.md) +- [Architecture](ARCHITECTURE.md) +- [UML and system flows](docs/architecture/UML.md) +- [ERD and persistence boundary](docs/architecture/ERD.md) +- [API contract](docs/product/API_CONTRACT.md) +- [Threat model](docs/THREAT_MODEL.md) +- [Test strategy](docs/product/TEST_STRATEGY.md) +- [Operability](docs/product/OPERABILITY.md) +- [Compliance traceability](docs/product/COMPLIANCE_TRACEABILITY.md) +- [Release, rollback and provenance](docs/product/RELEASE_PROVENANCE.md) +- [Product/engineering traceability](docs/product/TRACEABILITY.md) +- [ADR index](docs/adr/README.md) +- [Standards and research](docs/doctoring/REFERENCES.md) +- [Documentation home](docs/index.md) + +Repository-maintenance automation is documented in [`docs/hourly-autonomous-maintenance.md`](docs/hourly-autonomous-maintenance.md); it is not part of the customer-facing runtime contract. + +## Contributing and support + +For a behavior change, keep the public API, architecture/ADR evidence, security invariants, tests and package metadata aligned. Do not weaken fail-closed behavior or coverage to make a change pass. Report security-sensitive findings through the repository's security process rather than publishing exploit details in a public issue. + +For integration support, start with the API contract and architecture documents above. Host-specific provider credentials, tenancy, network perimeter and deployment policy remain the host owner's responsibility. ## License -Apache-2.0. See [LICENSE](LICENSE). +EgressWeave original source is licensed under the **Apache License 2.0**. See [`LICENSE`](LICENSE). Third-party dependencies retain their own licenses; the root grant does not relicense them. diff --git a/tests/test_documentation_automation_governance.py b/tests/test_documentation_automation_governance.py index ce71017..6c4b442 100644 --- a/tests/test_documentation_automation_governance.py +++ b/tests/test_documentation_automation_governance.py @@ -123,15 +123,15 @@ def test_traceability_maps_automation_governance_to_decision_and_evidence() -> N def test_protected_product_handoff_has_no_repository_local_publisher() -> None: - """Keep README, accepted ADR, and root architecture aligned on publisher-free handoff.""" - readme = _read("README.md") + """Keep operator docs, accepted ADR, and architecture aligned on publisher-free handoff.""" + operator_docs = _read("docs/hourly-autonomous-maintenance.md") accepted_adr = _read("docs/adr/0001-security-boundaries-and-modular-integration.md") architecture = _read("ARCHITECTURE.md") - readme_automation = readme.split("## Autonomous maintenance", 1)[1].split( - "## Version compatibility", + operator_automation = operator_docs.split( + "## Zero-PR product-development loop", 1, - )[0] + )[1].split("## Model change boundary", 1)[0] adr_automation = accepted_adr.split("### 6. Credential-separated automation", 1)[ 1 ].split("## Alternatives considered", 1)[0] @@ -140,8 +140,9 @@ def test_protected_product_handoff_has_no_repository_local_publisher() -> None: 1, )[0] - assert "credential-free" in readme_automation - assert "no repository-local job obtains write authority or" in readme_automation + normalized_operator = " ".join(operator_automation.split()).lower() + assert "credential-free" in normalized_operator + assert "no repository-local product-development job promotes" in normalized_operator for section in (adr_automation, architecture_automation): normalized = " ".join(section.split()).lower() diff --git a/tests/test_hourly_opencode_nvidia_contract.py b/tests/test_hourly_opencode_nvidia_contract.py index a5df254..e56b7b6 100644 --- a/tests/test_hourly_opencode_nvidia_contract.py +++ b/tests/test_hourly_opencode_nvidia_contract.py @@ -29,20 +29,46 @@ def _read(path: Path) -> str: return path.read_text(encoding="utf-8") +def _workflow_step(workflow: str, name: str) -> str: + """Return one named workflow step with comments removed.""" + marker = f" - name: {name}\n" + assert marker in workflow + step = workflow.split(marker, 1)[1].split("\n - name: ", 1)[0] + return "\n".join( + line for line in step.splitlines() if not line.lstrip().startswith("#") + ) + + def test_product_scheduler_uses_pinned_opencode_with_nvidia_nim() -> None: """Replace the Codex scheduler model step without mutable agent tooling.""" workflow = _read(PRODUCT_WORKFLOW_PATH) + install_step = _workflow_step(workflow, "Install the pinned OpenCode CLI") + lines = install_step.splitlines() + curl_line = ( + " curl --proto '=https' --tlsv1.2 --fail --location --silent " + "--show-error \\" + ) + expected_chain = [ + ' --output "$archive" \\', + ( + ' "https://github.com/anomalyco/opencode/releases/download/' + 'v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz"' + ), + ( + " printf '%s %s\\n' " + '"$OPENCODE_SHA256" "$archive" | sha256sum --check -' + ), + ] assert "openai/codex-action@" not in workflow assert "OPENAI_API_KEY" not in workflow assert "NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}" in workflow - assert f'OPENCODE_VERSION: "{OPENCODE_VERSION}"' in workflow - assert f'OPENCODE_SHA256: "{OPENCODE_LINUX_X64_SHA256}"' in workflow - assert ( - "https://github.com/anomalyco/opencode/releases/download/" - "v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" - ) in workflow - assert "sha256sum --check" in workflow + assert f'OPENCODE_VERSION: "{OPENCODE_VERSION}"' in install_step + assert f'OPENCODE_SHA256: "{OPENCODE_LINUX_X64_SHA256}"' in install_step + assert 'archive="${RUNNER_TEMP}/opencode-linux-x64.tar.gz"' in install_step + curl_index = lines.index(curl_line) + assert lines[curl_index + 1 : curl_index + 4] == expected_chain + assert "curl | sh" not in install_step assert "opencode run --auto" in workflow assert f'OPENCODE_MODEL: "{NVIDIA_MODEL}"' in workflow @@ -167,14 +193,17 @@ def test_operator_documentation_forbids_repository_local_patch_publication() -> assert "reconstruct and verify the exact tree" in documentation -def test_buyer_readme_identifies_the_opencode_nvidia_maintainer() -> None: - """Keep the public execution identity aligned with the audited workflow.""" +def test_buyer_readme_keeps_maintainer_identity_in_operator_documentation() -> None: + """Keep credential-bearing maintenance detail out of buyer-facing copy.""" readme = _read(README_PATH) + documentation = _read(MAINTENANCE_DOCUMENTATION_PATH) assert "bounded Codex maintainer" not in readme - assert "bounded OpenCode maintainer" in readme - assert "`NVIDIA_NIM_API_KEY`" in readme - assert "COPILOT_GITHUB_TOKEN" not in readme + assert "OpenCode" not in readme + assert "NVIDIA_NIM_API_KEY" not in readme + assert f"OpenCode {OPENCODE_VERSION}" in documentation + assert "`NVIDIA_NIM_API_KEY`" in documentation + assert "COPILOT_GITHUB_TOKEN" not in documentation def test_product_workflow_keeps_printf_escapes_on_indented_yaml_lines() -> None: