diff --git a/.gitignore b/.gitignore index 592927d9..78507a05 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ temp/ *.temp *.bak .scratch/ +plans/ # Python __pycache__/ diff --git a/plans/egress-gate-message-ledger.md b/plans/egress-gate-message-ledger.md index 77888a11..a1e78012 100644 --- a/plans/egress-gate-message-ledger.md +++ b/plans/egress-gate-message-ledger.md @@ -1,5 +1,9 @@ # Egress Gate message fingerprinting and denied-history ledger plan +> **Status:** Superseded for managed harnesses by send-time whole-context +> admission. This design remains relevant to unmanaged harnesses that cannot +> provide an attested provider context. + ## Scope The first release is intentionally process-local and in-memory. It does not diff --git a/projects/egress-gate/.gitignore b/projects/egress-gate/.gitignore new file mode 100644 index 00000000..3b9932d3 --- /dev/null +++ b/projects/egress-gate/.gitignore @@ -0,0 +1 @@ +.workspaces/ diff --git a/projects/egress-gate/.openshell-middleware-manifest.json b/projects/egress-gate/.openshell-middleware-manifest.json index 59d9d114..0d0ec995 100644 --- a/projects/egress-gate/.openshell-middleware-manifest.json +++ b/projects/egress-gate/.openshell-middleware-manifest.json @@ -1,7 +1,8 @@ { - "openshell_version": "v0.0.97", - "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.97/proto/supervisor_middleware.proto", - "proto_sha256": "e9d5a992ff5b50a33e9625176aaf6df8496d6774aa2ef3afe5cae7bc83c01105", + "openshell_version": "johnnygreco/OpenShell@8332c459c89124499e76da0a4095af9661aec10f", + "proto_source": "https://raw.githubusercontent.com/johnnygreco/OpenShell/8332c459c89124499e76da0a4095af9661aec10f/proto/supervisor_middleware.proto", + "proto_sha256": "2bda09fcbabc37663fbddfb8c49b6ae2689b25f3315912b0e8416a6bd2ac8e50", + "contract_note": "EvaluateAgentConversation is fork-only until the agent-conversation contract is upstreamed.", "languages": [ "python" ], diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 1471f75e..f7820a2b 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -24,7 +24,7 @@ commands work from any directory and do not depend on repository-only files: egress-gate gates list egress-gate gates schema egress-gate validate --policy /absolute/path/to/your-policy.yaml -egress-gate serve --listen 127.0.0.1:50051 +egress-gate serve --listen 127.0.0.1:50051 --no-require-agent-attestation ``` ## Source-checkout quickstart @@ -39,7 +39,7 @@ uv run egress-gate gates list uv run egress-gate gates schema uv run egress-gate validate \ --policy examples/regex-redaction/egress-gate-config.yaml -uv run egress-gate serve --listen 127.0.0.1:50051 +uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-agent-attestation uv run egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml @@ -49,6 +49,14 @@ Use `0.0.0.0` only when the OpenShell supervisor must reach the service across network namespaces. The development server uses plaintext gRPC. Restrict its listen port to trusted networks. +The CLI requires managed Pi context attestations by default, coupling admission +to provider egress verification. The general Gate quickstarts opt out +explicitly. Keep the default, or pass `--require-agent-attestation`, for a +managed harness; use `--no-require-agent-attestation` only for an intentionally +unmanaged deployment. +See the [managed Pi example](examples/pi-attested-admission/README.md) for the +matching Pi and OpenShell fork branches, startup contract, and current limits. + ## Policy shape The registry builds an exact strict schema from installed gate types: @@ -87,7 +95,7 @@ need initialization, helper bases, or typed resources use the full class-based ```bash uv run egress-gate --registry my_gates:registry gates list -uv run egress-gate --registry my_gates:registry serve +uv run egress-gate --registry my_gates:registry serve --no-require-agent-attestation ``` OpenShell owns interception, routing, and credential attachment. Egress Gate @@ -103,11 +111,14 @@ from egress_gate.service import EgressGateServer server = EgressGateServer( create_builtin_registry(), timeout_middleware_processing=10, + require_agent_attestation=False, ) server.serve_sync("127.0.0.1:50051") ``` -In this example, `timeout_middleware_processing` gives each evaluation 10 +Make the `require_agent_attestation` choice explicit in programmatic deployments; +set it to `True` for a managed harness. In this unmanaged example, +`timeout_middleware_processing` gives each evaluation 10 seconds. Omitting it uses the one-second service default. The value is expressed in seconds, must be at least 10 milliseconds, and must resolve to whole milliseconds. The service passes one resulting `Timeout` through slot @@ -136,6 +147,7 @@ timeout failures must deny. - [Architecture](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/architecture/index.md) - [Limits and failures](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/reference/limits-and-failures.md) - [Regex redaction composition](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/regex-redaction) +- [Pi attested-admission example](examples/pi-attested-admission/README.md) - [Function-based custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) - [Class-based custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/class-based-gate) diff --git a/projects/egress-gate/analysis/qa-reports/2026-09-02.html b/projects/egress-gate/analysis/qa-reports/2026-09-02.html new file mode 100644 index 00000000..6492be42 --- /dev/null +++ b/projects/egress-gate/analysis/qa-reports/2026-09-02.html @@ -0,0 +1,161 @@ + + + + + + + Pi attested-admission integration QA — 2026-09-02 + + + +
+

Pi attested-admission integration QA

+

Date: 2026-09-02 UTC

+

Live-session update: 2026-09-03 UTC, through 02:40:14

+

+ This report records the final proof-of-concept integration state across + Pi, OpenShell, and OpenShell Research. It contains only sanitized command + summaries: no credentials, request bodies, model output, environment + values, or local workspace paths. +

+ +

Reviewed revisions

+ + + + + + + +
RepositoryIntegration headUpstream base
Pi177b42723d072b7954a5b1690ccf62c97f075b37e266507b606b9552fa277252644054afd4384b11
OpenShell4d7194dc8166bfafc7236e0212d2e88aee4f7231a6b757d35f983fe4415427484ad06533d32e9e4b
OpenShell Researchff8319a69255ce0f858691095c6ea9f24d9b603f743839dae47621c13a3bc339bad2a2c8d0167591
+ +

Validation

+ + + + + + + + +
RepositoryResult
PiFocused suite passed: 38/38 after the assistant atomic-deny fix. npm run check passed its preliminary gates and reported only unchanged upstream packages/ai catalog TypeScript drift.
OpenShellPassed: pre-commit, the full repository test and CI lanes, and mise run go:ci. The merged proxy-delivery end-to-end case passed; the broader Docker lane later failed a separate policy-reload case because its sandbox emitted no JSON result.
OpenShell ResearchPassed: make check and make check-py311, 377/377 tests in each environment after the upstream merge, plus formatting, lint, typing, dependency audit, 11/11 documentation-renderer tests, the clean strict documentation build, and an HTTP 200 artifact preview.
Independent reviewPreviously reviewed Pi and OpenShell work clean: no blocker, high, or medium findings. The later proxy-delivery branch integration was validated but not independently re-reviewed when this report was updated.
+ +

Live end-to-end result

+

+ The configured machine completed the real ./demo.sh verify + workflow and an extended interactive Pi session. The required verifier + cases passed: denied input was not written, redacted input was persisted + only as [REDACTED], unauthorized bridge and unattested + provider calls were rejected, stock Pi could not bypass attestation, and + an admitted tool-result replacement persisted correctly. The + model-dependent tool-denial case skipped because the model did not choose + to call Bash; this is the verifier's documented non-failing outcome. +

+ +

Exploratory session review

+

+ The reviewed session is the persistent Pi JSONL session created at + 2026-09-03T01:45:53Z, ID + 01a064f1-a65a-7fe8-ab58-e20a67f868d7. The review used the + session structure and sanitized Egress Gate decision log; it did not copy + credentials or provider request bodies into this report. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AreaObserved behaviorFinding
Standard Pi workflowOne 158-entry JSONL session preserved 14 user messages, 76 assistant messages, 63 tool results, model and thinking-level changes, and one compaction event. Pi created and edited six chapter files and resumed work across repeated turns.Working. Persistent sessions, the normal tools, workspace writes, long-running interaction, and the standard TUI path were active.
Credential isolationWhen Pi inspected every PI_* environment variable, it saw model, provider, reasoning, and session metadata but no model API key or OpenShell resolver value.Working. Proxy-delivered provider authentication kept the credential out of the agent environment.
Reasoning controlsThe session began at high, changed to xhigh, and persisted 72 assistant thinking blocks. Provider usage separately reported reasoning tokens.Working as a model feature. The model produced reasoning and Pi retained it as ordinary session state.
CompactionPi compacted after a 69,090-token turn. The next provider request used 26,727 input tokens, then read the on-disk progress ledger and returned an accurate current story summary.Working. The compacted summary describes only the discarded prefix and looks stale in isolation, but firstKeptEntryId retains the Chapter 4 completion and the retained tail contains Chapters 5 and 6.
Provider failureImmediately after switching to xhigh, one continue submission produced four retries containing Connection error., empty content, and zero token usage.Failure, not denial. The session does not contain an HTTP status or lower-level cause, so it cannot distinguish the provider, proxy, or network source. The later request succeeded without a configuration change; correlation does not prove that xhigh caused it.
Reasoning-only completionThe next continue request returned 810 output tokens, of which 807 were reported as reasoning. It stopped successfully with one thinking block, no visible text, and no tool call. The user had to submit write it before work continued.Usability failure. This was a provider completion accepted by Pi, not an admission denial. It should first be reproduced against the same endpoint with stock Pi before changing the integration.
Denied-message auditAll 14 user entries visible in the reviewed JSONL were persisted, but an input denied before append is intentionally absent. The content-safe Egress Gate log has request IDs but no session ID, submission ID, or timestamp that can correlate a denial to this session.Observability gap. Pi can prove what was appended; it cannot prove from its own history whether another submitted message was denied. The model's claim that no denial occurred was therefore stronger than its evidence.
Thinking admissionAssistant thinking is persisted, can be sent on later provider turns, and consumes context. The current append envelope and attested context omit it; only request-time policy scanning covers it when it appears on the wire.Security-model gap. The current proof of concept does not guarantee that all persisted or provider-visible reasoning was admitted and bound into the context attestation.
Sandbox utilitiesThe model attempted to use bc and file, which are absent from the image. Both Bash tool results were recorded as successful because a later command in each shell invocation exited successfully.Minor environment/diagnostic issue. It did not stop the workflow, but demonstrates that a successful tool result does not imply every command in a compound shell command succeeded.
+ +

Decision-log evidence and limits

+

+ Since the latest Egress Gate server-start record, the content-safe log + contains 80 allows and two denials with + reason_code=attestation_missing. It contains no middleware + errors and no regex-denial reason. This supports the conclusion that the + visible interactive failures were not middleware denials. It cannot prove + which session or submission produced a record because the current log + deliberately omits the correlation fields needed for that join. +

+ +

Recommended follow-up

+
    +
  1. Add content-free denial audit metadata that can be correlated by sandbox, session, and submission without retaining the denied text or adding it to model context.
  2. +
  3. Bring assistant thinking inside the same append-admission and whole-context attestation contract as other persisted provider-visible content.
  4. +
  5. Reproduce the reasoning-only completion and the four connection retries with stock Pi against the same endpoint. Preserve standard Pi behavior unless the integration is shown to be responsible.
  6. +
  7. Expose enough transport error detail to distinguish an upstream/provider failure from a middleware denial without logging request content or credentials.
  8. +
+ +

Verified design boundary

+

+ One policy fingerprint connects append-time admission, provider-context + admission, and provider egress. The attestation binds one hash of the + complete ordered user/tool context. System/developer and assistant content + is scanned by the request policy at egress but is not included in that + hash. Denied additions do not enter Pi's live or persisted history. +

+ +

Known limits

+ +
+ + diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md new file mode 100644 index 00000000..0bd429eb --- /dev/null +++ b/projects/egress-gate/docs/architecture/admission.md @@ -0,0 +1,84 @@ +--- +title: Managed harness admission +description: Pi context admission, whole-context attestations, and egress verification. +agent_markdown: true +--- + +# Managed harness admission + +Managed Pi sessions use the same Egress Gate policy at three checkpoints. The +append and provider-context checkpoints apply policy to supported message +content before it enters history or is sent. Egress verification supplies the +security boundary: a provider request without a valid attestation is denied +before credentials are attached. + +| Checkpoint | Pi hook | Result | +| --- | --- | --- | +| History append | `user_message`, `tool_result`, `assistant_message`, `compaction_summary`, `branch_summary`, `extension_message`, or `bash_execution` | Allow, deny, or replace supported content before append | +| Provider context | `provider_context` | Allow, deny, or replace the complete ordered user/tool context and issue an attestation | +| Network egress | OpenShell pre-credentials middleware | Verify the attestation against the provider request, then run request policy | + +Assistant append admission covers finalized text and tool calls. Assistant +thinking is not append-admitted or included in the attested user/tool context +hash; request policy scans it at egress. Tool calls are inspectable and +denyable but immutable: a redaction targeting their ID, name, or arguments +fails closed rather than changing them. + +Append-time allows do not carry attestations. Immediately before a provider +request, Pi submits the complete context so retries, continuations, compaction, +queued input, and restored sessions do not depend on the newest entry alone. +OpenShell retains the signed attestation and returns only an opaque handle to +the runtime adapter. + +## Attestation and verification + +An `agent-attestation.v2` claim set binds the canonical context hash and entry +count to the harness and schema versions, middleware binding, policy +fingerprint, sandbox, session and submission identifiers, provider adapter, +provider host and port, signing-key identifier, and issue and expiry times. The +attestation is signed with the Egress Gate instance's ephemeral Ed25519 key and +expires after 300 seconds. + +At egress, Egress Gate: + +1. requires the network enforcement point, rejects the reserved handle header, + and requires an attestation; +2. parses the provider request with the selected OpenAI request adapter and + derives its complete ordered user/tool context; +3. verifies the signature, key, lifetime, trusted context fields, entry count, + and context hash; +4. runs the configured request gate pipeline; and +5. parses the resulting request again and denies if policy mutation changed the + attested semantic context. + +OpenShell attaches proxy-delivered credentials only after this middleware +allows the request. + +## Failures and limits + +Admission payloads and replacements are limited to 4 MiB. The middleware +manifest advertises the registered harness, hook, schema, and limit. Image +inputs are not supported by the Pi adapter and fail closed. Provider-context +admission currently runs before Pi's transport-specific history rewrites, so +switching transports with tool history or sending an orphaned tool call can +also fail closed. + +Stable admission failures include `admission_contract_invalid` and +`admission_unavailable`. Egress verification failures include +`network_context_invalid`, `reserved_header_present`, `attestation_missing`, +`attestation_malformed`, +`attestation_signature_invalid`, `attestation_key_mismatch`, +`attestation_not_yet_valid`, `attestation_expired`, +`attestation_context_mismatch`, `entry_count_mismatch`, +`context_hash_mismatch`, `provider_shape_unsupported`, +`semantic_mutation_denied`, and `egress_verification_failed`. A configured gate +may instead return its own deny reason. + +An Egress Gate started with `--require-agent-attestation` is dedicated to +managed harness traffic: unattested matching provider requests fail closed. +The supervisor-owned loopback bridge requires a per-exec capability delivered +to the launched harness on an inherited file descriptor. The launcher reads and +closes that descriptor and deletes its environment name before Pi starts, so +tool subprocesses do not receive the capability. The token remains in Pi's +memory; a same-user process able to read that memory could copy it, though the +sandbox's process isolation and ptrace restrictions reduce this residual risk. diff --git a/projects/egress-gate/docs/architecture/index.md b/projects/egress-gate/docs/architecture/index.md index 81ebd3ac..8d7dd044 100644 --- a/projects/egress-gate/docs/architecture/index.md +++ b/projects/egress-gate/docs/architecture/index.md @@ -71,4 +71,6 @@ the shared deadline checks. A failed candidate leaves the existing policy unchanged. Gate instances are reused across worker threads, so per-request state must remain local to `evaluate`. -See [Request lifecycle](request-lifecycle.md) and [Service boundary](service-boundary.md). +See [Request lifecycle](request-lifecycle.md), +[Service boundary](service-boundary.md), and +[Managed harness admission](admission.md). diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example new file mode 100644 index 00000000..d9a72595 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -0,0 +1,5 @@ +EGRESS_GATE_HOST_IP=YOUR_HOST_IPV4 +PI_MODELS_PATH=./models.json +PI_MODEL_API_KEY=your-provider-key +# Optional: omit this to start Pi in an empty workspace. +# PI_WORKSPACE_PATH=/absolute/path/to/your/project diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md new file mode 100644 index 00000000..99f06794 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -0,0 +1,341 @@ +# Pi attested-admission example + +This example runs the normal forked Pi CLI inside OpenShell and sends admitted +conversation context to the configured NVIDIA inference endpoint. One +endpoint-scoped provider and credential serve all configured models. + +The example demonstrates the same policy, identified by the same fingerprint, +at three checkpoints: before Pi appends history, immediately before Pi sends +its complete provider context, and again at provider egress before OpenShell +attaches credentials. + +- `DENY_THIS` is rejected before Pi adds a user message or tool result to its + live context. +- `REDACT_THIS` becomes `[REDACTED]` before Pi adds or sends it. + +The redaction case makes one real request to your configured endpoint and may +incur charges from that provider. + +## Before you start + +Use these matching fork branches: + +- [Pi `johnny/before-user-message-commit`](https://github.com/johnnygreco/pi/tree/johnny/before-user-message-commit) +- [OpenShell `openshell/pi-egress-admission`](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) +- [OpenShell Research integration branch](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission) + +You do not need to clone the Pi or OpenShell forks manually. The first +`./demo.sh prepare` clones both into the ignored local workspace +`projects/egress-gate/.workspaces/pi-attested-admission/`. Later runs update +them with fast-forward-only pulls, so the fork contents never appear as +OpenShell Research changes. To reuse a checkout elsewhere, set `PI_REPO` or +`OPENSHELL_REPO` to its absolute path. + +The OpenShell gateway needs a running compute backend. On macOS, start Docker +Desktop and wait until `docker info` succeeds before running the gateway; +Podman is also supported. Building the gateway also requires Z3 (`brew install +z3` on macOS or `libz3-dev` on Debian and Ubuntu). The fork recommends `mise` +2026.4.25 or newer. + +From the `OpenShell-Research` checkout, change to the example directory. Run +all remaining commands there: + +```shell +cd projects/egress-gate/examples/pi-attested-admission +``` + +Create the local configuration file and replace every example value: + +```shell +cp .env.example .env +# Edit .env before continuing. +``` + +Every `demo.sh` invocation loads this file automatically. The values remain +local to the script and its child commands; they are not added to your current +shell. Set `PI_EGRESS_ENV_FILE` to use a configuration file elsewhere. + +If the model endpoint does not require authentication, set +`PI_MODEL_API_KEY=unused`. + +`PI_WORKSPACE_PATH` is optional. Set it to the absolute path of a project you +want Pi to work on. The reset step uploads its contents to `/sandbox/workspace` +using the project's normal `.gitignore` rules. If you omit it, Pi starts in an +empty `/sandbox/workspace`; no local files are copied. In either case, Pi starts +there, so project instructions, extensions, skills, prompts, and session +grouping follow its ordinary current-directory behavior. + +`PI_MODELS_PATH` points to a standard Pi `models.json`. Relative paths are +resolved from this example directory. The checked-in [models.json](models.json) +defines one `attested-provider`, its endpoint and credential reference, and +these models: + +| Model ID | Pi transport | +| --- | --- | +| `azure/anthropic/claude-opus-5` | OpenAI Chat Completions | +| `azure/openai/gpt-5.6-sol` | OpenAI Responses | +| `nvidia/qwen/qwen3.8-flash-next` | OpenAI Chat Completions | + +Pi starts with the reasoning-capable Qwen model and `high` thinking from +[settings.json](settings.json). Use Pi's normal model picker to switch among all +three without creating another OpenShell provider. Qwen uses Chat Completions +reasoning controls, and GPT-5.6 Sol uses Responses reasoning. +The endpoint's Opus 5 alias currently rejects explicit adaptive-thinking +controls, so it runs with the endpoint's default thinking behavior. + +To use another catalog for the same endpoint, copy `models.json`, edit it using +Pi's documented JSON format, and set `PI_MODELS_PATH` to that file. OpenShell +pins network and credential access independently of Pi. To change endpoints, +update the matching host and port explicitly in `models.json`, `policy.yaml`, +and `provider-profile.yaml`. The automated `verify` cases target the checked-in +NVIDIA endpoint and model catalog; the script does not parse arbitrary catalogs +to adapt those checks. + +`EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this +machine. It must be a reachable, non-loopback IPv4 address; do not use +`127.0.0.1`. The provider's `baseUrl` in `models.json` is the model endpoint Pi +will call. A model server running on this machine must likewise use a hostname +or address reachable from the sandbox rather than `localhost`. + +The example checks in ordinary Pi and OpenShell configuration files. It uploads +`models.json` and `settings.json` unchanged to Pi's standard +`~/.pi/agent` directory. The only generated configuration is a copy of +`gateway-middleware.toml.example` with `EGRESS_GATE_HOST_IP` substituted for its +documented placeholder. If an action needs configuration that is missing, the +script prints the values required by that action and stops before doing work. + +Preview the complete workflow before running anything: + +```shell +./demo.sh --print all +``` + +The walkthrough lists the terminal sequence and configuration visible to the +current shell. To inspect the exact commands for one action, use its name—for +example, `./demo.sh --print prepare` or `./demo.sh --print launch`. + +## Run the example + +Prepare the forks and build and package the locally modified Pi agent core and +coding agent: + +```shell +./demo.sh prepare +``` + +The updates use fast-forward-only pulls and stop instead of merging divergent +local work. + +Keep Egress Gate running in one terminal: + +```shell title="Terminal 1: Egress Gate" +./demo.sh serve +``` + +This starts a managed-harness-only Egress Gate instance and writes content-safe +evaluation records to `/tmp/pi-egress-runtime/egress-gate.jsonl`. Set +`EGRESS_GATE_LOG` to use another path. Request bodies, headers, and message text +are not written to this log. + +Start the matching OpenShell gateway in a second terminal: + +```shell title="Terminal 2: OpenShell gateway" +./demo.sh gateway +``` + +The example uses its own gateway name and passes it explicitly to every +OpenShell command. It does not depend on or change your globally selected +OpenShell gateway. + +After the gateway reports that it is ready, create the demo sandbox from a +third terminal. `reset` is deliberately named: it deletes any prior demo +sandbox and its sessions before uploading the current runtime, configuration, +and workspace. + +```shell title="Terminal 3: Pi" +./demo.sh reset +``` + +Run the complete non-interactive verification: + +```shell title="Terminal 3: verify the example" +./demo.sh verify +``` + +`verify` runs the real packaged Pi and OpenShell sandbox. It checks a denied +prompt without a session write, a persisted redaction with a successful model +response, an unauthenticated call +to the admission bridge, a raw provider request without an admission handle, +the stock Pi binary without the runtime adapter, and best-effort tool-result +cases. Each case uses a fresh session and prints one `PASS` or `SKIP` line. Tool +cases can skip because choosing to call a tool is model-dependent; the other +cases are required. There is no mock fallback. Run `./demo.sh --print verify` +to inspect every underlying sandbox command. + +To explore interactively afterward, launch Pi: + +```shell title="Terminal 3: Pi" +./demo.sh launch +``` + +This runs Pi's standard CLI with a trusted runtime extension. Unlike ordinary +user and project extensions, a runtime extension is installed by the launcher, +supplies mandatory runtime boundaries, and is not affected by +`--no-extensions`. Pi still owns argument parsing, the TUI, settings, ordinary +extensions, tools, model selection, compaction, and session storage. `launch` +only enters the existing sandbox; it does not replace the sandbox or Pi's +state. Exit and run `launch` again to use Pi's normal `/resume` flow and +persistent JSONL sessions. Run `reset` only when you intentionally want a fresh +sandbox or need to apply a new runtime, policy, model configuration, credential, +or workspace snapshot. + +The sandbox image adds the `fd` and `rg` executables used by Pi's standard +`find` and `grep` tools. Pi itself still comes from the prepared fork package +and starts without restrictive CLI flags. Its standard user and project +resource discovery, extension loading, tools, model picker, thinking controls, +compaction, and session manager remain active. OpenShell's filesystem and +network policy still apply to every process in the sandbox; arbitrary package +downloads are intentionally outside this endpoint-focused example. + +The example registers an endpoint-specific provider profile using the host-side +`PI_MODEL_API_KEY`. Its `delivery: proxy` setting keeps the credential and any +resolver placeholder out of the sandbox. Pi sends the non-secret placeholder +declared by `models.json`; after admission and middleware processing succeed, +the OpenShell supervisor replaces that authorization header with the real, +endpoint-bound credential immediately before forwarding the request. + +At the Pi prompt, submit both of these in the same session: + +```text +Reply with exactly: DENY_THIS +``` + +```text +Reply with exactly: REDACT_THIS +``` + +The first submission is denied without starting a model request. The submitted +text is not appended, but Pi does not restore it to the editor after denial. +The second makes a request containing `[REDACTED]`. + +To exercise tool-result admission without putting the marker in the user +message, ask Pi: + +```text +Use bash to print the concatenation of DENY_ and THIS, then tell me the output. +``` + +The tool runs, but its result is replaced by Pi's protocol-safe blocked result +before it enters live context. Repeat with `REDACT_` and `THIS` to see the tool +result admitted as `[REDACTED]`. + +Pi uses its standard session manager and JSONL session location, and exposes +the active path to tools as `PI_SESSION_FILE`. Every supported history origin +passes the same generic append boundary before it reaches that history: user +messages, tool results, finalized assistant output, summaries, extension +messages, and bash executions. `launch` preserves the history; `reset` and +`cleanup` delete it with the sandbox. + +## How it works + +1. Pi exposes a provider-neutral `runCli()` entrypoint and a `RuntimeExtension` + interface for mandatory `ContextAdmission` hooks. The TypeScript + [openshell-pi.ts](runtime-extension/openshell-pi.ts) launcher calls that + entrypoint with the OpenShell adapter from + [openshell-context-admission.ts](runtime-extension/openshell-context-admission.ts). + `prepare` type-checks both files against the packaged Pi API and compiles + them to JavaScript for the sandbox. Pi otherwise starts normally, including + standard project and user extension discovery. +2. Pi calls that boundary before each supported message reaches live or + persisted history. Assistant text and tool calls are admitted when the + assistant message is finalized, after streamed output has already been + displayed. Assistant thinking is outside this append-time envelope; request + policy scans it at egress, but the context attestation does not hash it. + Assistant tool calls are inspectable and denyable but immutable; a redaction + targeting one fails closed. +3. OpenShell gives the launched runtime an inherited descriptor containing its + per-exec bridge token. The launcher reads and closes the descriptor and + deletes its environment name before Pi or its extensions start. The external + adapter sends the token with the exact context addition to OpenShell's + sandbox-local bridge. Egress Gate applies `policy.yaml` and returns allow, + deny, or a complete replacement. This append-time checkpoint returns no + attestation or handle. +4. Immediately before every provider request, Pi passes the exact outbound + context through admission. This includes normal turns, retries, compaction, + branch summaries, and contexts restored from a prior session. The adapter + applies per-entry replacements and obtains one fresh handle bound to one hash + of the complete ordered user/tool context. System/developer and assistant + content is scanned by request policy at egress but is not included in that + attested context hash. +5. OpenShell keeps the signed whole-context attestation and gives Pi only the + opaque handle, which the adapter keeps outside Pi messages. At egress, + OpenShell strips the handle and supplies the attestation only to the + configured Egress Gate stage. Egress Gate verifies the same ordered entries + before and after request policy runs, before OpenShell injects the + proxy-delivered model credential. + +The two Pi checkpoints serve different purposes. Append-time admission keeps +the UI, live context, and session file consistent with policy. Provider-context +admission covers every entry actually selected for the request, including +history introduced by retries, compaction, continuations, or session restore. +The egress checkpoint is the enforcement boundary: without a matching fresh +attestation, OpenShell does not attach the credential or forward the request. + +This division is intentional. The Pi fork contributes only reusable harness +primitives: generic append admission for every supported history origin, +including finalized assistant output, summaries, extension messages, and bash +executions; admission of the exact provider context; an outbound-header +transformation; and a standard-CLI entrypoint that accepts those hooks. +OpenShell contributes the sandbox-local bridge, signed attestations, +attestation-to-request binding, middleware enforcement, and post-policy +credential delivery. The TypeScript files under +`runtime-extension/` are the reusable integration layer that translates between +those generic Pi hooks and the OpenShell protocol; no OpenShell-specific code is +built into Pi. + +The supervisor mints a separate admission token for each `sandbox exec`, accepts +it only while that process is running, and rejects bridge calls without a valid +token. Because the launcher consumes the descriptor before starting Pi, tool +subprocesses receive neither the descriptor nor its environment name. Setting +`OPENSHELL_AGENT_ADMISSION_REQUIRE_CALLER_TOKEN=false` when starting the +OpenShell supervisor disables this check for debugging; this example keeps the +secure default. + +## Current scope + +The attestation adapter supports normal text turns, text tool results, queued +steering and follow-up messages, retries, automatic model continuations, +compaction, branch summaries, and restored sessions using the OpenAI Chat +Completions and Responses wire formats. Image inputs are outside this example's +current scope and fail closed. + +Provider-context admission runs before Pi's transport-specific history +rewrites. Switching transports with existing tool history or sending orphaned +tool calls may therefore fail closed. Start a fresh session when switching +transports, and complete each tool-call/result sequence before sending. + +Admission handles and their attestations expire after 300 seconds. Pi refreshes +them immediately before ordinary requests, but a provider retry that begins +more than five minutes later is denied. An Egress Gate started with +`--require-agent-attestation` serves managed harnesses only; an ordinary client +using the same middleware registration is denied because it has no attestation. + +The per-exec token remains in the Pi process's memory. A same-user process that +can read that memory could copy it; the sandbox's process isolation and ptrace +restrictions reduce this residual risk but do not make the token hardware-bound. + +## Cleanup + +Exit Pi, but leave the OpenShell gateway running while cleanup deletes the +sandbox and provider: + +```shell +./demo.sh cleanup +``` + +If the gateway is unavailable, `cleanup` stops before changing anything and +prints the exact commands needed to restart the local services. Cleanup goes +through OpenShell so sandbox and provider state are removed consistently. + +Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. For another +session in the same prepared sandbox, use `./demo.sh launch` instead of cleanup. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh new file mode 100755 index 00000000..54defffc --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -0,0 +1,852 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +set -euo pipefail + +print_only=false +if [[ ${1:-} == "--print" ]]; then + print_only=true + shift +fi + +action=${1:-help} +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +egress_gate_dir=$(cd -- "$script_dir/../.." && pwd) +env_file=${PI_EGRESS_ENV_FILE-$script_dir/.env} +if [[ -n $env_file && -f $env_file ]]; then + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a +fi + +forks_dir=${PI_EGRESS_FORKS_DIR:-$egress_gate_dir/.workspaces/pi-attested-admission} +pi_repo=${PI_REPO:-$forks_dir/pi} +openshell_repo=${OPENSHELL_REPO:-$forks_dir/OpenShell} +pi_branch=johnny/before-user-message-commit +openshell_branch=openshell/pi-egress-admission +pi_remote=https://github.com/johnnygreco/pi.git +openshell_remote=https://github.com/johnnygreco/OpenShell.git +host_ip=${EGRESS_GATE_HOST_IP:-YOUR_HOST_IPV4} +models_path_value=${PI_MODELS_PATH:-YOUR_MODELS_PATH} +if [[ $models_path_value == /* || $models_path_value == YOUR_MODELS_PATH ]]; then + models_path=$models_path_value +else + models_path=$script_dir/${models_path_value#./} +fi +workspace_path=${PI_WORKSPACE_PATH:-} +pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} +runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} +openshell_cli=$openshell_repo/scripts/bin/openshell +gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} +runtime_policy=$script_dir/policy.yaml +runtime_provider_profile=$script_dir/provider-profile.yaml +runtime_gateway_fragment=$runtime_dir/gateway-middleware.toml +gateway_fragment_template=$script_dir/gateway-middleware.toml.example +pi_settings=$script_dir/settings.json +runtime_extension_source=$script_dir/runtime-extension +runtime_extension_build=$runtime_dir/integration +egress_gate_log=${EGRESS_GATE_LOG:-$runtime_dir/egress-gate.jsonl} +z3_library_path_override=${Z3_LIBRARY_PATH_OVERRIDE:-} + +bold="" +green="" +yellow="" +blue="" +cyan="" +reset="" +if [[ ${NO_COLOR+x} != x && (${FORCE_COLOR:-0} == 1 || (-t 1 && ${TERM:-} != dumb)) ]]; then + bold=$'\033[1m' + green=$'\033[32m' + yellow=$'\033[33m' + blue=$'\033[34m' + cyan=$'\033[36m' + reset=$'\033[0m' +fi + +print_command() { + local directory=$1 + shift + local argument + local column=2 + local token + printf ' %bworking directory%b: %s\n' "$cyan" "$reset" "$directory" + printf ' %bcommand%b:\n ' "$green" "$reset" + for argument in "$@"; do + printf -v token '%q' "$argument" + if ((column > 2 && column + ${#token} + 1 > 96)); then + printf ' \\\n ' + column=6 + fi + if ((column > 2)); then + printf ' ' + ((column += 1)) + fi + printf '%s' "$token" + ((column += ${#token})) + done + printf '\n' +} + +describe_printed_commands() { + if $print_only; then + printf '\n%b%s%b\n' "$bold$blue" "$1" "$reset" + fi +} + +run_in() { + local directory=$1 + shift + if $print_only; then + print_command "$directory" "$@" + else + (cd -- "$directory" && "$@") + fi +} + +require_file() { + local path=$1 + local description=$2 + if [[ ! -f $path ]]; then + printf 'Missing %s: %s\n' "$description" "$path" >&2 + exit 1 + fi +} + +require_file_contains() { + local path=$1 + local expected_text=$2 + local description=$3 + require_file "$path" "$description" + if ! grep -Fq -- "$expected_text" "$path"; then + printf '%s is missing the required admission hook: %s\n' "$description" "$path" >&2 + printf 'Run `./demo.sh prepare` to rebuild the local Pi runtime.\n' >&2 + exit 1 + fi +} + +require_directory() { + local path=$1 + local description=$2 + if [[ ! -d $path ]]; then + printf 'Missing %s: %s\n' "$description" "$path" >&2 + exit 1 + fi +} + +require_compute_backend() { + local requested_driver=${OPENSHELL_DRIVERS:-} + if [[ -n ${KUBERNETES_SERVICE_HOST:-} ]]; then + return + fi + if [[ -z $requested_driver || $requested_driver == podman ]]; then + if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then + return + fi + fi + if [[ -z $requested_driver || $requested_driver == docker ]]; then + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + return + fi + fi + if [[ -n $requested_driver && $requested_driver != podman && $requested_driver != docker ]]; then + return + fi + + printf 'No running OpenShell compute backend was detected.\n' >&2 + printf 'Start Docker Desktop or Podman, wait until its info command succeeds, then retry:\n' >&2 + printf ' docker info\n' >&2 + printf ' # or: podman info\n' >&2 + printf 'For another supported backend, set OPENSHELL_DRIVERS before running gateway.\n' >&2 + exit 1 +} + +raise_gateway_open_file_limit() { + local target=10240 + local hard_limit + local soft_limit + hard_limit=$(ulimit -Hn) + soft_limit=$(ulimit -Sn) + if [[ $soft_limit == unlimited ]]; then + return + fi + if [[ $hard_limit != unlimited && $hard_limit -lt $target ]]; then + target=$hard_limit + fi + if ((soft_limit >= target)); then + return + fi + if ! ulimit -Sn "$target"; then + printf 'Could not raise the open-file limit from %s to %s for the OpenShell build.\n' \ + "$soft_limit" "$target" >&2 + printf 'Run `ulimit -n %s` in this terminal, then retry.\n' "$target" >&2 + exit 1 + fi +} + +require_gateway_z3() { + local z3_prefix + if [[ -n $z3_library_path_override ]]; then + return + fi + if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists z3; then + return + fi + case $(uname -s) in + Darwin) + if command -v brew >/dev/null 2>&1; then + z3_prefix=$(brew --prefix z3 2>/dev/null || true) + if [[ -f $z3_prefix/lib/libz3.dylib ]]; then + z3_library_path_override=$z3_prefix/lib + return + fi + fi + printf 'The OpenShell gateway build requires Z3. Install it, then retry:\n' >&2 + printf ' brew install z3\n' >&2 + ;; + Linux) + if command -v ldconfig >/dev/null 2>&1 && ldconfig -p 2>/dev/null | grep -q 'libz3\.so'; then + return + fi + printf 'The OpenShell gateway build requires the Z3 development library.\n' >&2 + printf 'On Debian or Ubuntu, install it with: sudo apt-get install libz3-dev\n' >&2 + ;; + *) + printf 'The OpenShell gateway build requires the Z3 native library.\n' >&2 + printf 'Install Z3 or set Z3_LIBRARY_PATH_OVERRIDE to its library directory.\n' >&2 + ;; + esac + exit 1 +} + +require_host_configuration() { + if [[ -n ${EGRESS_GATE_HOST_IP:-} && ${EGRESS_GATE_HOST_IP:-} != YOUR_HOST_IPV4 ]]; then + return + fi + printf 'Set EGRESS_GATE_HOST_IP in %s before starting the gateway.\n' "$env_file" >&2 + exit 1 +} + +require_setup_configuration() { + local missing=() + if [[ -z ${EGRESS_GATE_HOST_IP:-} || ${EGRESS_GATE_HOST_IP:-} == YOUR_HOST_IPV4 ]]; then + missing+=(EGRESS_GATE_HOST_IP) + fi + if [[ -z ${PI_MODELS_PATH:-} || ${PI_MODELS_PATH:-} == YOUR_MODELS_PATH ]]; then + missing+=(PI_MODELS_PATH) + fi + if [[ -z ${PI_MODEL_API_KEY:-} || ${PI_MODEL_API_KEY:-} == your-provider-key ]]; then + missing+=(PI_MODEL_API_KEY) + fi + if ((${#missing[@]} == 0)); then + require_file "$models_path" "Pi model configuration" + if [[ -n $workspace_path ]]; then + require_directory "$workspace_path" "Pi workspace" + fi + return + fi + + printf 'The Pi attested-admission example is not configured.\n' >&2 + printf 'Set these environment variables:\n' >&2 + printf ' %s\n' "${missing[@]}" >&2 + printf '\n' >&2 + printf 'Configure %s:\n' "$env_file" >&2 + printf ' cd %s\n' "$script_dir" >&2 + if [[ ! -f $env_file ]]; then + printf ' cp .env.example .env\n' >&2 + fi + printf ' # Edit .env and replace every example value.\n' >&2 + exit 1 +} + +require_branch() { + local repository=$1 + local expected=$2 + local actual + actual=$(git -C "$repository" branch --show-current) + if [[ $actual != "$expected" ]]; then + printf 'Expected %s to be on branch %s, but found %s.\n' "$repository" "$expected" "${actual:-detached HEAD}" >&2 + exit 1 + fi +} + +ensure_checkout() { + local repository=$1 + local description=$2 + local remote=$3 + local branch=$4 + local parent + parent=$(dirname -- "$repository") + if $print_only; then + describe_printed_commands "$description (only when missing):" + print_command "$parent" git clone --branch "$branch" "$remote" "$repository" + return + fi + if [[ -e $repository && ! -d $repository/.git ]]; then + printf '%s path exists but is not a Git checkout: %s\n' "$description" "$repository" >&2 + exit 1 + fi + if [[ ! -d $repository/.git ]]; then + mkdir -p "$parent" + run_in "$parent" git clone --branch "$branch" "$remote" "$repository" + fi +} + +sync_forks() { + ensure_checkout "$pi_repo" "Pi checkout" "$pi_remote" "$pi_branch" + ensure_checkout "$openshell_repo" "OpenShell checkout" "$openshell_remote" "$openshell_branch" + if ! $print_only; then + require_branch "$pi_repo" "$pi_branch" + require_branch "$openshell_repo" "$openshell_branch" + fi + describe_printed_commands "Update the Pi fork:" + run_in "$pi_repo" git pull --no-rebase --ff-only origin "$pi_branch" + describe_printed_commands "Update the OpenShell fork:" + run_in "$openshell_repo" git pull --no-rebase --ff-only origin "$openshell_branch" +} + +pi_package_tarball() { + local package_directory=$1 + local archive_name=$2 + if $print_only; then + printf '%s/%s-VERSION.tgz' "$pack_dir" "$archive_name" + return + fi + require_file "$package_directory/package.json" "Pi package" + local version + version=$(node -p "require(process.argv[1]).version" "$package_directory/package.json") + printf '%s/%s-%s.tgz' "$pack_dir" "$archive_name" "$version" +} + +prepare_gateway_configuration() { + if $print_only; then + describe_printed_commands "Write the one host-specific gateway setting:" + printf ' %bsource%b: %s\n' "$cyan" "$reset" "$gateway_fragment_template" + printf ' %boutput%b: %s\n' "$green" "$reset" "$runtime_gateway_fragment" + printf ' Replace YOUR_HOST_IPV4 with %s.\n' "$host_ip" + return + fi + require_file "$gateway_fragment_template" "gateway middleware configuration template" + mkdir -p "$runtime_dir" + sed "s/YOUR_HOST_IPV4/$host_ip/" "$gateway_fragment_template" >"$runtime_gateway_fragment" +} + +prepare() { + sync_forks + local agent_tarball + local coding_agent_tarball + agent_tarball=$(pi_package_tarball "$pi_repo/packages/agent" "earendil-works-pi-agent-core") + coding_agent_tarball=$(pi_package_tarball "$pi_repo/packages/coding-agent" "earendil-works-pi-coding-agent") + + describe_printed_commands "Build and package Pi:" + run_in "$pi_repo" npm install --ignore-scripts + run_in "$pi_repo" npm run build:offline + run_in "$pi_repo" mkdir -p "$pack_dir" "$runtime_dir" + run_in "$pi_repo" npm pack --workspace @earendil-works/pi-agent-core --pack-destination "$pack_dir" + run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" + run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$agent_tarball" "$coding_agent_tarball" + describe_printed_commands "Type-check and compile the trusted runtime extension:" + run_in "$pi_repo" mkdir -p "$runtime_dir/integration-src" "$runtime_extension_build" + run_in "$pi_repo" cp -R "$runtime_extension_source/." "$runtime_dir/integration-src" + run_in "$runtime_dir/integration-src" "$pi_repo/node_modules/.bin/tsgo" -p tsconfig.json + run_in "$runtime_dir/integration-src" cp package.json "$runtime_extension_build/package.json" +} + +serve() { + describe_printed_commands "Run Egress Gate and keep it open:" + run_in "$egress_gate_dir" uv run egress-gate --debug serve \ + --listen 0.0.0.0:50051 --timeout 4s --require-agent-attestation \ + --json-log "$egress_gate_log" +} + +gateway() { + if ! $print_only; then + require_host_configuration + require_compute_backend + raise_gateway_open_file_limit + require_gateway_z3 + require_directory "$openshell_repo" "OpenShell checkout" + require_branch "$openshell_repo" "$openshell_branch" + fi + prepare_gateway_configuration + # A custom checkout may be nested below this uv project. Keep OpenShell's + # mise-pinned uv from inheriting Egress Gate's uv configuration. + describe_printed_commands "Start the matching OpenShell gateway and keep it open:" + run_in "$openshell_repo" env UV_NO_CONFIG=1 mise trust + local gateway_environment=( + env + UV_NO_CONFIG=1 + CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" + OPENSHELL_GATEWAY_NAME="$gateway_name" + OPENSHELL_GATEWAY_CONFIG_FRAGMENT="$runtime_gateway_fragment" + ) + if [[ -n $z3_library_path_override ]]; then + gateway_environment+=(Z3_LIBRARY_PATH_OVERRIDE="$z3_library_path_override") + fi + run_in "$openshell_repo" "${gateway_environment[@]}" mise run gateway +} + +ensure_model_provider() { + if $print_only; then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider delete pi-model + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile delete pi-attested-model + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile import --file "$runtime_provider_profile" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY + return + fi + if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + provider get pi-model >/dev/null 2>&1); then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider delete pi-model + fi + if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + provider profile export pi-attested-model >/dev/null 2>&1); then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile delete pi-attested-model + fi + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile import --file "$runtime_provider_profile" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY +} + +delete_demo_sandbox_if_present() { + if $print_only; then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox delete pi-egress-demo + return + fi + if ! $print_only && (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + sandbox list --names | grep -Fxq pi-egress-demo); then + printf 'Replacing existing sandbox pi-egress-demo with the current example runtime.\n' + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox delete pi-egress-demo + fi +} + +create_demo_sandbox() { + run_in "$script_dir" "$openshell_cli" --gateway "$gateway_name" sandbox create \ + --name pi-egress-demo \ + --from "$script_dir/sandbox" \ + --provider pi-model \ + --policy "$runtime_policy" \ + --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ + --upload "$models_path:/sandbox/.pi/agent/models.json" \ + --upload "$pi_settings:/sandbox/.pi/agent/settings.json" \ + --upload "$runtime_extension_build/openshell-context-admission.js:/sandbox/pi-runtime/integration/openshell-context-admission.js" \ + --upload "$runtime_extension_build/openshell-pi.js:/sandbox/pi-runtime/integration/openshell-pi.js" \ + --upload "$runtime_extension_build/package.json:/sandbox/pi-runtime/integration/package.json" \ + --no-git-ignore \ + --detach + if [[ -n $workspace_path ]]; then + describe_printed_commands "Upload the selected workspace with its .gitignore rules:" + run_in "$workspace_path" "$openshell_cli" --gateway "$gateway_name" sandbox upload \ + pi-egress-demo . /sandbox/workspace + elif $print_only; then + describe_printed_commands "No workspace selected; Pi starts in an empty /sandbox/workspace." + fi +} + +reset_demo() { + if ! $print_only; then + require_setup_configuration + require_file "$openshell_cli" "OpenShell CLI wrapper" + require_file "$(pi_package_tarball "$pi_repo/packages/agent" "earendil-works-pi-agent-core")" \ + "packed Pi agent core" + require_file "$(pi_package_tarball "$pi_repo/packages/coding-agent" "earendil-works-pi-coding-agent")" \ + "packed Pi coding-agent" + require_file_contains \ + "$runtime_dir/node_modules/@earendil-works/pi-agent-core/dist/agent-loop.js" \ + "beforeToolResultAppend" \ + "installed Pi agent core" + require_file_contains \ + "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/index.js" \ + "runCli" \ + "installed Pi SDK" + require_file "$pi_settings" "Pi settings" + require_file "$runtime_extension_build/openshell-context-admission.js" \ + "compiled OpenShell context-admission adapter" + require_file "$runtime_extension_build/openshell-pi.js" "compiled OpenShell Pi launcher" + require_file "$runtime_extension_build/package.json" "runtime-extension package metadata" + require_file "$runtime_policy" "OpenShell sandbox policy" + require_file "$runtime_provider_profile" "OpenShell provider profile" + fi + + describe_printed_commands "Remove an earlier example sandbox, if present:" + delete_demo_sandbox_if_present + describe_printed_commands "Register the endpoint-scoped model credential in OpenShell:" + ensure_model_provider + describe_printed_commands "Create a fresh sandbox and upload the Pi runtime:" + create_demo_sandbox + printf 'The demo sandbox is ready. Run: ./demo.sh launch\n' +} + +require_demo_sandbox() { + if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + sandbox list --names | grep -Fxq pi-egress-demo); then + return + fi + printf 'The pi-egress-demo sandbox does not exist. Create it with: ./demo.sh reset\n' >&2 + exit 1 +} + +launch() { + if ! $print_only; then + require_file "$openshell_cli" "OpenShell CLI wrapper" + require_demo_sandbox + fi + describe_printed_commands "Launch Pi interactively in the prepared sandbox:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec --tty -n pi-egress-demo --workdir /sandbox/workspace -- \ + env \ + OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ + node /sandbox/pi-runtime/integration/openshell-pi.js +} + +run_sandbox_command() { + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec --no-tty -n pi-egress-demo --workdir /sandbox/workspace -- "$@" +} + +capture_sandbox_command() { + local output=$1 + local error=$2 + shift 2 + if $print_only; then + run_sandbox_command "$@" + return + fi + (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec --no-tty -n pi-egress-demo --workdir /sandbox/workspace -- "$@") \ + >"$output" 2>"$error" +} + +copy_session() { + local session_file=$1 + local output=$2 + local error=$3 + capture_sandbox_command "$output" "$error" /bin/cat "$session_file" +} + +log_line_count() { + if [[ -f $egress_gate_log ]]; then + wc -l <"$egress_gate_log" + else + printf '0\n' + fi +} + +assert_logged_reason() { + local first_line=$1 + local reason=$2 + local label=$3 + if ! awk -v first="$first_line" -v reason="\"reason_code\":\"$reason\"" \ + 'NR >= first && index($0, reason) { found = 1 } END { exit !found }' \ + "$egress_gate_log"; then + printf '%s did not produce reason code %s in %s.\n' "$label" "$reason" "$egress_gate_log" >&2 + exit 1 + fi + printf 'PASS %-18s reason_code=%s\n' "$label" "$reason" +} + +verify() { + local verify_id="$(date +%s)-$$" + local verify_dir="/sandbox/pi-admission-verify/$verify_id" + local bridge_url="http://127.0.0.1:8193/v1/agent/conversation" + local temporary_dir + local output + local error + local status + local session + local first_log_line + local raw_request_script='fetch("https://inference-api.nvidia.com/v1/chat/completions", {method:"POST", headers:{"content-type":"application/json", authorization:"Bearer openshell-proxy"}, body:JSON.stringify({model:"nvidia/qwen/qwen3.8-flash-next", messages:[{role:"user",content:"hello"}]})}).then(async response => { console.error(`provider status=${response.status}`); process.exit(response.ok ? 0 : 1); }).catch(error => { console.error(String(error)); process.exit(1); });' + + if $print_only; then + describe_printed_commands "Create fresh real Pi sessions and run every verification case:" + temporary_dir=/tmp/pi-admission-verify + else + require_file "$openshell_cli" "OpenShell CLI wrapper" + require_demo_sandbox + if [[ ! -f $egress_gate_log ]]; then + printf 'Missing Egress Gate JSON log: %s\n' "$egress_gate_log" >&2 + printf 'Start Egress Gate with ./demo.sh serve before running verify.\n' >&2 + exit 1 + fi + temporary_dir=$(mktemp -d) + trap 'rm -rf -- "$temporary_dir"' RETURN + fi + + output=$temporary_dir/deny.out + error=$temporary_dir/deny.err + session=$verify_dir/deny.jsonl + status=0 + capture_sandbox_command "$output" "$error" env \ + OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ + node /sandbox/pi-runtime/integration/openshell-pi.js \ + --session "$session" -p "Reply with exactly: DENY_THIS" || status=$? + if ! $print_only; then + if ((status == 0)) || ! grep -Fq "OpenShell denied this context addition" "$error"; then + printf 'Denied-prompt verification failed. See %s and %s.\n' "$output" "$error" >&2 + exit 1 + fi + if capture_sandbox_command "$output.session" "$error.session" test -e "$session"; then + printf 'Denied prompt unexpectedly created a session: %s\n' "$session" >&2 + exit 1 + fi + printf 'PASS %-18s denied; session not written\n' "denied prompt" + fi + + output=$temporary_dir/redact.out + error=$temporary_dir/redact.err + session=$verify_dir/redact.jsonl + capture_sandbox_command "$output" "$error" env \ + OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ + node /sandbox/pi-runtime/integration/openshell-pi.js \ + --session "$session" -p "Reply with exactly: REDACT_THIS" + copy_session "$session" "$temporary_dir/redact.jsonl" "$temporary_dir/redact-session.err" + if ! $print_only; then + if ! grep -Fq "[REDACTED]" "$temporary_dir/redact.jsonl" || \ + grep -Fq "REDACT_THIS" "$temporary_dir/redact.jsonl"; then + printf 'Redacted-prompt verification failed for %s.\n' "$session" >&2 + exit 1 + fi + if ! grep -Fq '"role":"assistant"' "$temporary_dir/redact.jsonl" || \ + grep -Fq '"stopReason":"error"' "$temporary_dir/redact.jsonl"; then + printf 'Provider-response verification failed for %s.\n' "$session" >&2 + exit 1 + fi + printf 'PASS %-18s provider answered; session contains only [REDACTED]\n' \ + "redacted prompt" + fi + + capture_sandbox_command "$temporary_dir/bridge.out" "$temporary_dir/bridge.err" \ + /usr/bin/curl --silent --show-error \ + --header "content-type: application/json" \ + --data '{}' \ + --write-out $'\n%{http_code}\n' \ + "$bridge_url" + if ! $print_only; then + if ! grep -Fq '"error":"caller_not_authorized"' "$temporary_dir/bridge.out" || \ + ! tail -n 1 "$temporary_dir/bridge.out" | grep -Fxq 401; then + printf 'Unauthenticated bridge request was not rejected. See %s and %s.\n' \ + "$temporary_dir/bridge.out" "$temporary_dir/bridge.err" >&2 + exit 1 + fi + printf 'PASS %-18s caller_not_authorized\n' "raw bridge" + fi + + first_log_line=$(( $(log_line_count) + 1 )) + status=0 + capture_sandbox_command "$temporary_dir/raw.out" "$temporary_dir/raw.err" \ + /usr/bin/node -e "$raw_request_script" || status=$? + if ! $print_only; then + if ((status == 0)); then + printf 'Raw provider request unexpectedly succeeded.\n' >&2 + exit 1 + fi + assert_logged_reason "$first_log_line" attestation_missing "raw provider" + fi + + first_log_line=$(( $(log_line_count) + 1 )) + status=0 + capture_sandbox_command "$temporary_dir/stock.out" "$temporary_dir/stock.err" \ + /usr/bin/pi --session "$verify_dir/stock.jsonl" -p hello || status=$? + if ! $print_only; then + if ((status == 0)); then + printf 'Stock Pi unexpectedly reached the provider.\n' >&2 + exit 1 + fi + assert_logged_reason "$first_log_line" attestation_missing "stock Pi" + fi + + for marker in DENY REDACT; do + local marker_lower=${marker,,} + local expected="[REDACTED]" + if [[ $marker == DENY ]]; then + expected="[Tool result blocked by context admission]" + fi + session=$verify_dir/tool-$marker_lower.jsonl + capture_sandbox_command "$temporary_dir/tool-$marker_lower.out" "$temporary_dir/tool-$marker_lower.err" env \ + OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ + node /sandbox/pi-runtime/integration/openshell-pi.js --session "$session" -p \ + "Use bash to print the concatenation of ${marker}_ and THIS, then tell me the output." + copy_session "$session" "$temporary_dir/tool-$marker_lower.jsonl" \ + "$temporary_dir/tool-$marker_lower-session.err" + if ! $print_only; then + status=0 + awk -v expected="$expected" -v forbidden="${marker}_THIS" ' + index($0, "\"role\":\"toolResult\"") { + found = 1 + if (index($0, expected)) admitted = 1 + if (index($0, forbidden)) leaked = 1 + } + END { + if (!found) exit 2 + if (!admitted || leaked) exit 1 + } + ' "$temporary_dir/tool-$marker_lower.jsonl" || status=$? + if ((status == 0)); then + printf 'PASS %-18s tool result contains %s\n' "tool $marker_lower" "$expected" + elif ((status == 2)); then + printf 'SKIP %-18s model did not call bash\n' "tool $marker_lower" + else + printf 'Tool %s result was not safely admitted.\n' "$marker_lower" >&2 + exit 1 + fi + fi + done + + if $print_only; then + printf ' Assertions inspect each fresh session and %s; request content is never logged.\n' \ + "$egress_gate_log" + fi +} + +cleanup() { + if ! $print_only; then + require_file "$openshell_cli" "OpenShell CLI wrapper" + if ! (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + status >/dev/null 2>&1); then + cat >&2 <&2 + exit 1 + fi + print_plan + ;; + help | --help | -h) usage ;; + *) + printf 'Unknown action: %s\n\n' "$action" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example b/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example new file mode 100644 index 00000000..66c7d565 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example @@ -0,0 +1,6 @@ +[[openshell.supervisor.middleware]] +name = "pi-egress" +grpc_endpoint = "http://YOUR_HOST_IPV4:50051" +allow_insecure_transport = true +max_payload_bytes = 4194304 +timeout = "30s" diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json b/projects/egress-gate/examples/pi-attested-admission/models.json new file mode 100644 index 00000000..5da24a7f --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/models.json @@ -0,0 +1,68 @@ +{ + "providers": { + "attested-provider": { + "baseUrl": "https://inference-api.nvidia.com/v1", + "apiKey": "openshell-proxy", + "models": [ + { + "id": "azure/anthropic/claude-opus-5", + "name": "Claude Opus 5", + "api": "openai-completions", + "reasoning": false, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 128000, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + } + }, + { + "id": "azure/openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "api": "openai-responses", + "reasoning": true, + "thinkingLevelMap": { + "off": "none", + "minimal": "low", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", + "max": "max" + }, + "input": ["text"], + "contextWindow": 1050000, + "maxTokens": 128000, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } + }, + { + "id": "nvidia/qwen/qwen3.8-flash-next", + "name": "Qwen3.8 Flash Next", + "api": "openai-completions", + "reasoning": true, + "thinkingLevelMap": { + "minimal": "low", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "high", + "max": "high" + }, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 32768, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsReasoningEffort": true, + "thinkingFormat": "qwen" + } + } + ] + } + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml new file mode 100644 index 00000000..1bdc86b5 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -0,0 +1,64 @@ +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + model_provider: + name: Configured model endpoint + endpoints: + - host: inference-api.nvidia.com + port: 443 + protocol: rest + enforcement: enforce + access: full + binaries: + - { path: /usr/bin/node } + - { path: /usr/local/bin/node } + +network_middlewares: + pi_egress_gate: + name: Admit rendered Pi prompts and inspect provider requests + middleware: pi-egress + order: 0 + config: + gates: + - name: deny-marker + kind: regex + scan: + kind: body + action: + kind: deny + pattern_catalog: + entities: + - name: unsafe-marker + rules: + - name: exact-deny-marker + pattern: DENY_THIS + confidence: high + - name: replace-marker + kind: regex + scan: + kind: body + action: + kind: replace + template: "[REDACTED]" + pattern_catalog: + entities: + - name: replacement-marker + rules: + - name: exact-replacement-marker + pattern: REDACT_THIS + confidence: high + default_decision: allow + on_error: fail_closed + endpoints: + include: + - inference-api.nvidia.com diff --git a/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml b/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml new file mode 100644 index 00000000..8980d48d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml @@ -0,0 +1,22 @@ +id: pi-attested-model +display_name: Pi attested-admission model +description: Endpoint-scoped model credential for the Pi attested-admission example +category: inference +inference_capable: true +credentials: + - name: api_key + description: Model provider API key + env_vars: [PI_MODEL_API_KEY] + required: true + delivery: proxy + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: inference-api.nvidia.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/node, /usr/local/bin/node] diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts new file mode 100644 index 00000000..8279c9f0 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts @@ -0,0 +1,464 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { + AssistantMessage, + Context, + ImageContent, + ProviderHeaders, + TextContent, + ToolResultMessage, + UserMessage, +} from "@earendil-works/pi-ai"; +import type { + ContextAdmission, + ContextAdmissionResult, + MessageOrigin, +} from "@earendil-works/pi-coding-agent"; + +const HANDLE_HEADER = "x-openshell-agent-admission-handle"; +const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; +const MAX_BRIDGE_RESPONSE_BYTES = MAX_ADMISSION_BYTES * 4 + 64 * 1024; +const MAX_HANDLE_ENTRIES = 1024; + +type ContentBlock = TextContent | ImageContent; +type MessageEnvelope = { + schema_version: "openshell.pi-message.v1"; + origin: "user" | "compaction_summary" | "branch_summary" | "extension_message"; + text: string; +}; +type ToolResultEnvelope = { + schema_version: "openshell.pi-tool-result.v1"; + tool_call_id: string; + tool_name: string; + content: ContentBlock[]; + is_error: boolean; +}; +type AssistantEnvelope = { + schema_version: "openshell.pi-assistant-message.v1"; + text: string; + tool_calls: { id: string; name: string; arguments: Record }[]; +}; +type BashEnvelope = { + schema_version: "openshell.pi-bash-execution.v1"; + command: string; + output: string; + exit_code: number | null; +}; +type ContextEntry = + | { role: "user"; text: string } + | { role: "tool"; tool_call_id: string; text: string }; +type ProviderContextEnvelope = { + schema_version: "openshell.pi-provider-context.v1"; + entries: ContextEntry[]; +}; +type AdmissionEnvelope = MessageEnvelope | ToolResultEnvelope | AssistantEnvelope | BashEnvelope; +type BridgeEnvelope = AdmissionEnvelope | ProviderContextEnvelope; +type AppendAdmissionHook = + | "user_message" + | "tool_result" + | "assistant_message" + | "compaction_summary" + | "branch_summary" + | "extension_message" + | "bash_execution"; +type AdmissionHook = AppendAdmissionHook | "provider_context"; +type BridgeResult = + | { decision: "deny"; reason_code?: string } + | { decision: "allow"; handle?: string; replacement_body?: Uint8Array }; + +type SummaryMessage = AgentMessage & { summary: string }; +type CustomMessage = AgentMessage & { content: string | ContentBlock[] }; +type BashMessage = AgentMessage & { command: string; output: string; exitCode: number | undefined }; + +export function createOpenShellContextAdmission( + bridgeUrl: string, + getSessionId: () => string, + admissionToken: string, + fetchRequest: typeof fetch = fetch, +): ContextAdmission { + const handles = new Map(); + + async function requestAdmission(hook: AdmissionHook, envelope: BridgeEnvelope): Promise { + const requestBody = new TextEncoder().encode(canonicalJson(envelope)); + if (requestBody.byteLength > MAX_ADMISSION_BYTES) { + throw new Error("OpenShell admission request is too large"); + } + const response = await fetchRequest(bridgeUrl, { + method: "POST", + headers: { + authorization: `Bearer ${admissionToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + harness_version: "sdk-v1", + hook, + schema_version: envelope.schema_version, + session_id: getSessionId(), + submission_id: crypto.randomUUID(), + request_body_b64: Buffer.from(requestBody).toString("base64"), + }), + }); + if (!response.ok) throw new Error("OpenShell admission is unavailable"); + const encoded = new Uint8Array(await response.arrayBuffer()); + if (encoded.byteLength > MAX_BRIDGE_RESPONSE_BYTES) { + throw new Error("OpenShell admission response is too large"); + } + return parseBridgeResult(JSON.parse(new TextDecoder().decode(encoded))); + } + + async function admitMessage( + message: T, + meta: { origin: MessageOrigin }, + ): Promise> { + const prepared = envelopeForMessage(message, meta.origin); + if (!prepared) { + return { action: "deny", reason: "Image inputs are not supported by OpenShell admission" }; + } + const result = await requestAdmission(prepared.hook, prepared.envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseReplacement(prepared.hook, result.replacement_body) + : prepared.envelope; + const admittedMessage = applyReplacement(message, meta.origin, admittedEnvelope); + return result.replacement_body ? { action: "allow", message: admittedMessage } : { action: "allow" }; + } + + return { + admitMessage, + async admitProviderContext(context) { + const envelope = providerContextEnvelope(context); + if (!envelope) { + return { action: "deny", reason: "Image inputs are not supported by OpenShell admission" }; + } + const result = await requestAdmission("provider_context", envelope); + if (result.decision === "deny") return denied(result.reason_code); + if (!result.handle) throw new Error("OpenShell admission returned no provider-context handle"); + const admittedEnvelope = result.replacement_body + ? parseProviderContextReplacement(result.replacement_body, envelope) + : envelope; + const admittedContext = applyProviderContextReplacement(context, admittedEnvelope.entries); + rememberHandle(handles, contextKey(admittedEnvelope), result.handle); + return result.replacement_body ? { action: "allow", context: admittedContext } : { action: "allow" }; + }, + async transformProviderHeaders(headers: ProviderHeaders, context: Context) { + if (Object.keys(headers).some((name) => name.toLowerCase() === HANDLE_HEADER)) { + throw new Error("OpenShell admission handle header is reserved"); + } + const envelope = providerContextEnvelope(context); + if (!envelope) throw new Error("Image inputs are not supported by OpenShell admission"); + const handle = handles.get(contextKey(envelope)); + if (handle) return { ...headers, [HANDLE_HEADER]: handle }; + throw new Error("OpenShell admission handle is missing for the outbound context"); + }, + }; +} + +function envelopeForMessage( + message: AgentMessage, + origin: MessageOrigin, +): { hook: AppendAdmissionHook; envelope: AdmissionEnvelope } | undefined { + switch (origin) { + case "user": { + if (message.role !== "user") throw new Error("Pi admission origin does not match the message"); + const envelope = textEnvelope("user", message.content); + return envelope && { hook: "user_message", envelope }; + } + case "tool_result": + if (message.role !== "toolResult") throw new Error("Pi admission origin does not match the message"); + return { hook: "tool_result", envelope: toolResultEnvelope(message) }; + case "assistant": + if (message.role !== "assistant") throw new Error("Pi admission origin does not match the message"); + return { hook: "assistant_message", envelope: assistantEnvelope(message) }; + case "compaction_summary": + if (message.role !== "compactionSummary") throw new Error("Pi admission origin does not match the message"); + return { + hook: "compaction_summary", + envelope: messageEnvelope("compaction_summary", (message as SummaryMessage).summary), + }; + case "branch_summary": + if (message.role !== "branchSummary") throw new Error("Pi admission origin does not match the message"); + return { + hook: "branch_summary", + envelope: messageEnvelope("branch_summary", (message as SummaryMessage).summary), + }; + case "extension_message": { + if (message.role !== "custom") throw new Error("Pi admission origin does not match the message"); + const envelope = textEnvelope("extension_message", (message as CustomMessage).content); + return envelope && { hook: "extension_message", envelope }; + } + case "bash_execution": { + if (message.role !== "bashExecution") throw new Error("Pi admission origin does not match the message"); + const bash = message as BashMessage; + return { + hook: "bash_execution", + envelope: { + command: bash.command, + exit_code: bash.exitCode ?? null, + output: bash.output, + schema_version: "openshell.pi-bash-execution.v1", + }, + }; + } + } +} + +function messageEnvelope(origin: MessageEnvelope["origin"], text: string): MessageEnvelope { + return { origin, schema_version: "openshell.pi-message.v1", text }; +} + +function textEnvelope(origin: MessageEnvelope["origin"], content: string | ContentBlock[]): MessageEnvelope | undefined { + if (typeof content === "string") return messageEnvelope(origin, content); + if (content.some((block) => block.type === "image")) return undefined; + return { + origin, + schema_version: "openshell.pi-message.v1", + text: content.map((block) => (block as TextContent).text).join("\n"), + }; +} + +function toolResultEnvelope(message: ToolResultMessage): ToolResultEnvelope { + return { + schema_version: "openshell.pi-tool-result.v1", + tool_call_id: message.toolCallId, + tool_name: message.toolName, + content: message.content, + is_error: message.isError, + }; +} + +function assistantEnvelope(message: AssistantMessage): AssistantEnvelope { + return { + schema_version: "openshell.pi-assistant-message.v1", + text: message.content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"), + tool_calls: message.content + .filter((block) => block.type === "toolCall") + .map(({ id, name, arguments: args }) => ({ arguments: args, id, name })), + }; +} + +function applyReplacement(message: T, origin: MessageOrigin, envelope: AdmissionEnvelope): T { + switch (origin) { + case "user": + return { ...message, content: replaceTextContent((message as UserMessage).content, (envelope as MessageEnvelope).text) }; + case "tool_result": + return { ...message, content: (envelope as ToolResultEnvelope).content }; + case "assistant": + return replaceAssistantText(message as AssistantMessage, (envelope as AssistantEnvelope).text) as T; + case "compaction_summary": + case "branch_summary": + return { ...message, summary: (envelope as MessageEnvelope).text }; + case "extension_message": + return { ...message, content: replaceTextContent((message as CustomMessage).content, (envelope as MessageEnvelope).text) }; + case "bash_execution": + return { ...message, output: (envelope as BashEnvelope).output }; + } + throw new Error("Pi admission origin is unsupported"); +} + +function replaceAssistantText(message: AssistantMessage, text: string): AssistantMessage { + const content: AssistantMessage["content"] = []; + let replaced = false; + for (const block of message.content) { + if (block.type !== "text") { + content.push(block); + } else if (!replaced) { + if (text) content.push({ type: "text", text }); + replaced = true; + } + } + if (!replaced && text) content.push({ type: "text", text }); + return { ...message, content }; +} + +function replaceTextContent(content: string | ContentBlock[], text: string): string | TextContent[] { + return typeof content === "string" ? text : [{ type: "text", text }]; +} + +function providerContextEnvelope(context: Context): ProviderContextEnvelope | undefined { + const entries: ContextEntry[] = []; + for (const message of context.messages) { + if (message.role === "user") { + const text = textContent(message.content); + if (text === undefined) return undefined; + entries.push({ role: "user", text }); + } else if (message.role === "toolResult") { + const text = textBlocks(message.content); + if (text === undefined) return undefined; + entries.push({ + role: "tool", + tool_call_id: message.toolCallId.split("|", 1)[0], + text: text || "(no tool output)", + }); + } + } + if (entries.length === 0) throw new Error("Provider context has no user message or tool result to admit"); + return { schema_version: "openshell.pi-provider-context.v1", entries }; +} + +function textContent(content: string | ContentBlock[]): string | undefined { + if (typeof content === "string") return content; + return textBlocks(content); +} + +function textBlocks(content: ContentBlock[]): string | undefined { + if (content.some((block) => block.type === "image")) return undefined; + return content.map((block) => (block as TextContent).text).join("\n"); +} + +function applyProviderContextReplacement(context: Context, entries: ContextEntry[]): Context { + let entryIndex = 0; + const messages = context.messages.map((message) => { + if (message.role !== "user" && message.role !== "toolResult") return message; + const entry = entries[entryIndex++]; + if (message.role === "user") { + if (entry.role !== "user") throw new Error("OpenShell admission changed provider-context structure"); + return { ...message, content: replaceTextContent(message.content, entry.text) }; + } + if (entry.role !== "tool" || entry.tool_call_id !== message.toolCallId.split("|", 1)[0]) { + throw new Error("OpenShell admission changed provider-context structure"); + } + return { ...message, content: [{ type: "text" as const, text: entry.text }] }; + }); + if (entryIndex !== entries.length) throw new Error("OpenShell admission changed provider-context structure"); + return { ...context, messages }; +} + +function contextKey(envelope: ProviderContextEnvelope): string { + return createHash("sha256").update(canonicalJson(envelope.entries)).digest("hex"); +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (!isRecord(value)) return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); +} + +function rememberHandle(handles: Map, key: string, handle: string): void { + handles.delete(key); + handles.set(key, handle); + if (handles.size > MAX_HANDLE_ENTRIES) { + const oldest = handles.keys().next().value; + if (oldest !== undefined) handles.delete(oldest); + } +} + +function denied(reasonCode?: string): { action: "deny"; reason: string } { + return { + action: "deny", + reason: reasonCode + ? `OpenShell denied this context addition (${reasonCode})` + : "OpenShell denied this context addition", + }; +} + +function parseBridgeResult(value: unknown): BridgeResult { + if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { + throw new Error("OpenShell admission returned an invalid response"); + } + if (value.decision === "deny") { + return { decision: "deny", reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined }; + } + if ( + value.handle !== undefined && + (typeof value.handle !== "string" || !value.handle || value.handle.length > 1024) + ) { + throw new Error("OpenShell admission returned an invalid handle"); + } + let replacementBody: Uint8Array | undefined; + if (value.replacement_body_b64 !== undefined) { + if (typeof value.replacement_body_b64 !== "string") { + throw new Error("OpenShell admission returned an invalid replacement"); + } + const decoded = Buffer.from(value.replacement_body_b64, "base64"); + if (decoded.toString("base64") !== value.replacement_body_b64 || decoded.byteLength > MAX_ADMISSION_BYTES) { + throw new Error("OpenShell admission returned an invalid replacement"); + } + replacementBody = decoded; + } + return { decision: "allow", handle: value.handle, replacement_body: replacementBody }; +} + +function parseProviderContextReplacement( + body: Uint8Array, + original: ProviderContextEnvelope, +): ProviderContextEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if ( + !isRecord(value) || + value.schema_version !== "openshell.pi-provider-context.v1" || + !Array.isArray(value.entries) || + value.entries.length !== original.entries.length + ) { + throw new Error("OpenShell admission returned an invalid provider-context replacement"); + } + const entries = value.entries.map((entry, index): ContextEntry => { + const expected = original.entries[index]; + if (!isRecord(entry) || entry.role !== expected.role || typeof entry.text !== "string") { + throw new Error("OpenShell admission changed provider-context structure"); + } + if (entry.role === "user" && entry.tool_call_id === undefined) return { role: "user", text: entry.text }; + if ( + entry.role === "tool" && + typeof entry.tool_call_id === "string" && + expected.role === "tool" && + entry.tool_call_id === expected.tool_call_id + ) { + return { role: "tool", tool_call_id: entry.tool_call_id, text: entry.text }; + } + throw new Error("OpenShell admission changed provider-context structure"); + }); + return { schema_version: "openshell.pi-provider-context.v1", entries }; +} + +function parseReplacement(hook: AppendAdmissionHook, body: Uint8Array): AdmissionEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if (!isRecord(value)) throw new Error("OpenShell admission returned an invalid replacement"); + switch (hook) { + case "user_message": + case "compaction_summary": + case "branch_summary": + case "extension_message": + if ( + value.schema_version !== "openshell.pi-message.v1" || + typeof value.origin !== "string" || + typeof value.text !== "string" + ) throw new Error("OpenShell admission returned an invalid message replacement"); + return value as MessageEnvelope; + case "tool_result": + if ( + value.schema_version !== "openshell.pi-tool-result.v1" || + typeof value.tool_call_id !== "string" || + typeof value.tool_name !== "string" || + !Array.isArray(value.content) || + typeof value.is_error !== "boolean" + ) throw new Error("OpenShell admission returned an invalid tool-result replacement"); + return value as ToolResultEnvelope; + case "assistant_message": + if ( + value.schema_version !== "openshell.pi-assistant-message.v1" || + typeof value.text !== "string" || + !Array.isArray(value.tool_calls) + ) throw new Error("OpenShell admission returned an invalid assistant replacement"); + return value as AssistantEnvelope; + case "bash_execution": + if ( + value.schema_version !== "openshell.pi-bash-execution.v1" || + typeof value.command !== "string" || + typeof value.output !== "string" || + (value.exit_code !== null && typeof value.exit_code !== "number") + ) throw new Error("OpenShell admission returned an invalid bash replacement"); + return value as BashEnvelope; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts new file mode 100644 index 00000000..5cd15793 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts @@ -0,0 +1,48 @@ +import { closeSync, readFileSync } from "node:fs"; +import { runCli, type RuntimeExtension } from "@earendil-works/pi-coding-agent"; + +import { createOpenShellContextAdmission } from "./openshell-context-admission.js"; + +const bridgeUrl = process.env.OPENSHELL_AGENT_CONVERSATION_URL; +if (!bridgeUrl) { + throw new Error("OPENSHELL_AGENT_CONVERSATION_URL is required"); +} + +const runtimeExtension = createRuntimeExtension(bridgeUrl, readAdmissionToken()); + +await runCli(process.argv.slice(2), { runtimeExtension }); + +function createRuntimeExtension(bridgeUrl: string, admissionToken: string): RuntimeExtension { + return { + createContextAdmission: (sessionManager) => + createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId(), admissionToken), + }; +} + +function readAdmissionToken(): string { + const tokenFdValue = process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD; + delete process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD; + if (!tokenFdValue || !/^\d+$/.test(tokenFdValue)) { + throw new Error("OPENSHELL_AGENT_ADMISSION_TOKEN_FD must name a readable file descriptor"); + } + + const tokenFd = Number(tokenFdValue); + let admissionToken: string; + try { + admissionToken = readFileSync(tokenFd, "utf8"); + } catch (cause) { + try { + closeSync(tokenFd); + } catch {} + throw new Error("Could not read the OpenShell agent admission token", { cause }); + } + try { + closeSync(tokenFd); + } catch (cause) { + throw new Error("Could not close the OpenShell agent admission token descriptor", { cause }); + } + if (!/^[A-Za-z0-9_-]{43}$/.test(admissionToken)) { + throw new Error("OpenShell supplied an invalid agent admission token"); + } + return admissionToken; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json new file mode 100644 index 00000000..e986b24b --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json new file mode 100644 index 00000000..cccd9db4 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmitOnError": true, + "outDir": "../integration", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": ["*.ts"] +} diff --git a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile new file mode 100644 index 00000000..d80e70b5 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM ghcr.io/nvidia/openshell-community/sandboxes/pi:latest + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends fd-find ripgrep \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && mkdir -p /sandbox/workspace \ + && chown sandbox:sandbox /sandbox/workspace \ + && rm -rf /var/lib/apt/lists/* +USER sandbox diff --git a/projects/egress-gate/examples/pi-attested-admission/settings.json b/projects/egress-gate/examples/pi-attested-admission/settings.json new file mode 100644 index 00000000..3b1578da --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/settings.json @@ -0,0 +1,5 @@ +{ + "defaultProvider": "attested-provider", + "defaultModel": "nvidia/qwen/qwen3.8-flash-next", + "defaultThinkingLevel": "high" +} diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index dbde411c..a51a57ba 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -9,7 +9,8 @@ import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; // SupervisorMiddleware lets an operator-run service inspect and transform -// sandbox HTTP egress before OpenShell injects credentials. +// sandbox HTTP requests and client WebSocket text messages before OpenShell +// injects credentials, or evaluate a supported agent-harness request. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -20,6 +21,20 @@ service SupervisorMiddleware { // EvaluateHttpRequest returns an allow, deny, or mutation decision for one // buffered HTTP request. rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); + + // EvaluateAgentConversation returns an allow, deny, or replacement decision for + // one versioned, harness-native request before the harness commits or sends it. + rpc EvaluateAgentConversation(AgentConversationEvaluation) returns (AgentConversationResult); + + // EvaluateWebSocketSession opens one ordered, phase-specific stream for a + // single middleware stage and WebSocket upgrade attempt. The current + // implementation supports client-to-upstream text messages at + // PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + // A request may go unanswered when the session terminates. For every opened + // stage stream, OpenShell attempts at most one session_end before closing the + // stream when its transport is still writable. + rpc EvaluateWebSocketSession(stream WebSocketSessionEvent) + returns (stream WebSocketSessionEventResult); } // MiddlewareManifest describes one middleware service and the bindings it @@ -34,22 +49,39 @@ message MiddlewareManifest { string service_version = 2; // Bindings exposed by this middleware service. repeated MiddlewareBinding bindings = 3; + // Exact JWT audience this service verifies on inbound OpenShell calls. + // After authenticated Describe succeeds, OpenShell rejects the registration + // unless this matches the operator-configured audience. A strict verifier may + // reject an incorrect audience before returning this manifest. Empty skips + // this post-authentication consistency check. + string expected_audience = 4; } // MiddlewareBinding declares one operation and phase supported by a service. message MiddlewareBinding { - // Supported operation. V1 supports HTTP_REQUEST. + // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. V1 supports PRE_CREDENTIALS. + // Supported evaluation phase. PR 1 supports PRE_CREDENTIALS. PRE_RETURN is + // reserved for the return-path follow-up and is rejected by current + // manifest validation. SupervisorMiddlewarePhase phase = 2; - // Maximum request or replacement body this binding can process. - uint64 max_body_bytes = 3; + // Maximum logical payload or replacement this binding can process. For + // HTTP_REQUEST and AGENT_CONVERSATION this is the request body; for + // WEBSOCKET_MESSAGE this is one complete message. Required for every + // payload-bearing operation. + uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. // A non-empty value may shorten but cannot extend the operator timeout. // Values use an integer with an `ms` or `s` suffix and must be between // 10ms and 30s. string timeout = 4; + // Agent harness supported by an AGENT_CONVERSATION binding. Empty otherwise. + string harness = 5; + // Harness hook supported by an AGENT_CONVERSATION binding. Empty otherwise. + string hook = 6; + // Version of the harness-native request schema. Empty otherwise. + string schema_version = 7; } // ValidateConfigRequest contains one policy configuration to validate. @@ -90,6 +122,9 @@ message HttpRequestEvaluation { bytes body = 6; // Built-in middleware name or operator-owned registration name. string middleware_name = 7; + // Supervisor-resolved agent attestation for this middleware stage. The + // workload cannot set or observe these bytes. Limited to 8 KiB. + bytes agent_attestation = 8; } // HttpHeader is one request header line. @@ -104,12 +139,153 @@ message HttpHeader { enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; + SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 3; } // Ordered phase within a supervisor operation. enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; + SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 3; +} + +// Why OpenShell is ending a middleware stream. +enum WebSocketSessionEndReason { + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED = 0; + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE = 1; + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT = 2; + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD = 3; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR = 6; + WEB_SOCKET_SESSION_END_REASON_CANCELLATION = 7; + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED = 8; + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL = 9; + // The middleware stage voluntarily declined inspection during preflight. + // This is a successful stage-local outcome, not a cancellation or denial of + // the WebSocket upgrade. + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED = 10; +} + +// WebSocketSessionEvent is one ordered event in a stage-local stream. +// Message sequence numbers identify logical messages session-wide. A stage +// receives a strictly increasing subset of those numbers; gaps are valid when +// session messages are not delivered to that stage. +message WebSocketSessionEvent { + oneof event { + WebSocketPreflight preflight = 1; + WebSocketSessionStart session_start = 2; + WebSocketMessage message = 3; + WebSocketSessionEnd session_end = 4; + } +} + +// WebSocketPreflight lets a service decline this upgrade before OpenShell +// contacts upstream. It deliberately excludes query data, arbitrary request +// headers, and message payloads. +message WebSocketPreflight { + string session_id = 1; + SupervisorMiddlewarePhase phase = 2; + RequestContext context = 3; + // Admitted HTTP WebSocket-upgrade target. The method is GET, query is always + // empty, and path never includes a query string. + HttpRequestTarget target = 4; + repeated string requested_subprotocols = 5; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 6; + google.protobuf.Struct config = 7; +} + +// WebSocketSessionStart reports bounded metadata known only after the +// upstream 101 response validates. Empty selected_subprotocol means none. +message WebSocketSessionStart { + string selected_subprotocol = 1; +} + +// WebSocketMessage contains one complete reconstructed logical message. +message WebSocketMessage { + // Session-global sequence starting at 1. Values delivered to one stage must + // strictly increase but need not be contiguous. Reject zero, duplicates, and + // regressions; accept gaps. + uint64 sequence = 1; + // One complete logical payload. Protobuf string decoding enforces UTF-8 for + // text messages. Raw frame mechanics are never exposed. Limited to 4 MiB by + // the platform and the binding-specific cap. + oneof payload { + string text = 2; + bytes binary = 3; + } +} + +// WebSocketSessionEnd is OpenShell's best-effort terminal notification for one +// opened stage stream. A stage receives at most one such notification. +message WebSocketSessionEnd { + WebSocketSessionEndReason reason = 1; +} + +// WebSocketPreflightAction is the service's one-time scoping decision. +enum WebSocketPreflightAction { + // Invalid response value handled according to the policy failure mode. + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED = 0; + // Inspect this session after the upstream accepts the upgrade. + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT = 1; + // Voluntarily decline inspection without denying the upgrade. This is a + // successful decision and does not engage on_error. + WEB_SOCKET_PREFLIGHT_ACTION_SKIP = 2; + // Authoritatively deny the upgrade before upstream contact. This is a + // successful decision and is enforced regardless of on_error. + WEB_SOCKET_PREFLIGHT_ACTION_DENY = 3; +} + +message WebSocketPreflightDecision { + WebSocketPreflightAction action = 1; + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 2; + // Optional stable machine-readable code for a deny decision. Because + // preflight runs before the HTTP upgrade completes, OpenShell may return + // this code to the requester. Codes follow the same format and 64-byte + // maximum as HttpRequestResult.reason_code. + string reason_code = 3; + // Audit-safe findings produced during preflight. At most 32 findings of at + // most 4 KiB encoded each are accepted. + repeated Finding findings = 4; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. + map metadata = 5; +} + +// WebSocketMessageResult contains the decision and optional replacement for +// one message. A replacement must use the same variant as the input payload. +message WebSocketMessageResult { + // Must exactly match the sequence of the corresponding WebSocketMessage. + uint64 sequence = 1; + Decision decision = 2; + // Absence preserves the input unchanged. Oneof presence distinguishes an + // empty replacement from no replacement, and string decoding enforces UTF-8. + oneof replacement { + string text = 3; + bytes binary = 4; + } + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 5; + // Optional stable machine-readable code for OCSF only. Unlike the HTTP + // reason_code, this value is never put in a WebSocket close frame. + string reason_code = 6; + repeated Finding findings = 7; + map metadata = 8; +} + +// WebSocketSessionEventResult is an evaluation result for a preflight or message +// event. Session start and end events do not produce results. +message WebSocketSessionEventResult { + oneof result { + WebSocketPreflightDecision preflight_decision = 1; + WebSocketMessageResult message_result = 2; + } } // RequestContext identifies the sandbox request being evaluated. @@ -120,11 +296,19 @@ message RequestContext { string sandbox_id = 2; // Workload process that originated the request, when available. Process originating_process = 3; + // Sandbox name that originated the request. For display and logging only. + // Names are workspace-scoped and may be reused for different sandbox + // instances, so consumers must use sandbox_id for authorization, persistence, + // durable correlation, and identity. + string sandbox_name = 4; + // Workspace the sandbox belongs to. For display and logging only; see the + // sandbox_name guidance above. + string workspace = 5; } // HttpRequestTarget describes the admitted HTTP destination and request target. message HttpRequestTarget { - // Request scheme, such as "http" or "https". + // Request scheme, such as "http", "https", "ws", or "wss". string scheme = 1; // Destination hostname selected by network policy. string host = 2; @@ -148,13 +332,58 @@ message Process { repeated string ancestors = 3; } -// Decision controls whether OpenShell continues processing the request. +// AgentConversationTarget identifies the harness hook and provider destination for +// which an allowed model request may receive a receipt. +message AgentConversationTarget { + string harness = 1; + string harness_version = 2; + string hook = 3; + string schema_version = 4; + string scheme = 5; + string host = 6; + uint32 port = 7; + string path = 8; +} + +// AgentConversationEvaluation is stamped by the supervisor-owned bridge. Workload +// callers supply only the harness request and untrusted request provenance. +message AgentConversationEvaluation { + SupervisorMiddlewarePhase phase = 1; + RequestContext context = 2; + google.protobuf.Struct config = 3; + AgentConversationTarget target = 4; + reserved 5; + string middleware_name = 6; + string session_id = 7; + string turn_id = 8; + bytes request_body = 9; + reserved 10 to 13; +} + +// AgentConversationResult carries the authority decision, an optional complete +// replacement body, and a model-request receipt opaque to OpenShell. +message AgentConversationResult { + Decision decision = 1; + string reason = 2; + reserved 3, 4; + bytes attestation = 5; + repeated Finding findings = 6; + map metadata = 7; + string reason_code = 8; + bytes replacement_body = 9; + bool has_replacement_body = 10; +} + +// Decision controls whether OpenShell continues processing the current +// evaluation unit. enum Decision { // Invalid response value handled according to the policy failure mode. DECISION_UNSPECIFIED = 0; - // Continue processing the request and apply any returned mutations. + // Continue processing the current request or message and apply any returned + // mutations. DECISION_ALLOW = 1; - // Deny the request before credentials are injected or data is sent upstream. + // Reject the current request or message. The operation-specific result + // defines the enclosing protocol behavior. DECISION_DENY = 2; } diff --git a/projects/egress-gate/pyproject.toml b/projects/egress-gate/pyproject.toml index 99254472..3aa84f68 100644 --- a/projects/egress-gate/pyproject.toml +++ b/projects/egress-gate/pyproject.toml @@ -10,6 +10,7 @@ authors = [ { name = "NVIDIA CORPORATION & AFFILIATES" }, ] dependencies = [ + "cryptography>=50,<51", "grpcio>=1.81.1,<2", "protobuf>=7.36,<8", # 7.36.0 fixes protobuf security advisories. "pydantic>=2.11,<3", diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py new file mode 100644 index 00000000..c453ed7c --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""First-class harness admission and attested-egress APIs.""" + +from egress_gate.admission.adapters import ( + AttestedEntries, + ContextEntryV1, + HarnessAdapter, + HarnessAdapterRegistry, + OpenAIChatCompletionsV1Adapter, + OpenAIResponsesV1Adapter, + PiAssistantMessageV1, + PiAssistantMessageV1Adapter, + PiAssistantToolCallV1, + PiBashExecutionV1, + PiBashExecutionV1Adapter, + PiImageContentV1, + PiMessageV1, + PiMessageV1Adapter, + PiProviderContextV1, + PiProviderContextV1Adapter, + PiTextContentV1, + PiToolResultV1, + PiToolResultV1Adapter, + PreparedHarnessRequest, + ProviderAdapterRegistry, + ProviderRequestAdapter, + ToolContextEntryV1, + UserContextEntryV1, + context_entries_subject, + create_pi_adapter_registry, + create_provider_adapter_registry, +) +from egress_gate.admission.canonical import ( + CanonicalFunctionCallV1, + CanonicalGenerationV1, + CanonicalMessageV1, + CanonicalRole, + CanonicalToolChoiceV1, + CanonicalToolV1, + ModelRequestV1, + canonical_json_bytes, +) +from egress_gate.admission.models import ( + MAX_ADMISSION_BODY_BYTES, + PI_HARNESS_VERSION, + AdmissionDecision, + AdmissionHook, + AdmissionProvenance, + HarnessAdmissionContext, + HarnessAdmissionRequest, + HarnessAdmissionResult, +) +from egress_gate.admission.processor import ( + RECEIPT_HEADER, + AttestedEgressProcessor, + HarnessAdmissionProcessor, +) +from egress_gate.admission.receipts import ( + AgentAttestationClaimsV2, + ReceiptAuthority, + ReceiptVerificationError, +) + +__all__ = [ + "AdmissionDecision", + "AdmissionHook", + "AdmissionProvenance", + "AgentAttestationClaimsV2", + "AttestedEntries", + "AttestedEgressProcessor", + "CanonicalFunctionCallV1", + "CanonicalGenerationV1", + "CanonicalMessageV1", + "CanonicalRole", + "CanonicalToolChoiceV1", + "CanonicalToolV1", + "ContextEntryV1", + "HarnessAdapter", + "HarnessAdapterRegistry", + "HarnessAdmissionContext", + "HarnessAdmissionProcessor", + "HarnessAdmissionRequest", + "HarnessAdmissionResult", + "MAX_ADMISSION_BODY_BYTES", + "PI_HARNESS_VERSION", + "ModelRequestV1", + "OpenAIChatCompletionsV1Adapter", + "OpenAIResponsesV1Adapter", + "PiAssistantMessageV1", + "PiAssistantMessageV1Adapter", + "PiAssistantToolCallV1", + "PiBashExecutionV1", + "PiBashExecutionV1Adapter", + "PiMessageV1", + "PiImageContentV1", + "PiTextContentV1", + "PiToolResultV1", + "PiToolResultV1Adapter", + "PiMessageV1Adapter", + "PiProviderContextV1", + "PiProviderContextV1Adapter", + "PreparedHarnessRequest", + "ProviderAdapterRegistry", + "ProviderRequestAdapter", + "RECEIPT_HEADER", + "ReceiptAuthority", + "ReceiptVerificationError", + "ToolContextEntryV1", + "UserContextEntryV1", + "canonical_json_bytes", + "context_entries_subject", + "create_pi_adapter_registry", + "create_provider_adapter_registry", +] diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py new file mode 100644 index 00000000..e0faa73c --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -0,0 +1,1207 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Registered Pi and provider request-shape adapters.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Literal, Protocol, TypeAlias + +from pydantic import ( + Field, + TypeAdapter, + ValidationError, + field_validator, + model_validator, +) + +from egress_gate.admission.canonical import ( + CanonicalFunctionCallV1, + CanonicalGenerationV1, + CanonicalMessageV1, + CanonicalRole, + CanonicalToolChoiceV1, + CanonicalToolV1, + ModelRequestV1, + canonical_json_bytes, +) +from egress_gate.admission.models import ( + AdmissionHook, + HarnessAdmissionContext, + HarnessAdmissionRequest, +) +from egress_gate.base import StrictDomainModel +from egress_gate.errors import BodyFormatError, GateInputError +from egress_gate.request import HttpRequest +from egress_gate.request_content import JsonDocument +from egress_gate.string_validators import ScalarString +from egress_gate.timeout import Timeout + + +class AdmissionShapeError(ValueError): + """A content-safe signal that an admission shape is unsupported.""" + + +class AdmissionMutationError(ValueError): + """A content-safe signal that a Gate changed a read-only field.""" + + +class ProviderShapeError(ValueError): + """A content-safe signal that a provider request is unsupported.""" + + +PiMessageOrigin: TypeAlias = Literal[ + "user", "compaction_summary", "branch_summary", "extension_message" +] + + +class PiMessageV1(StrictDomainModel): + """Text-bearing message submitted by the managed Pi harness.""" + + schema_version: Literal["openshell.pi-message.v1"] + origin: PiMessageOrigin + text: ScalarString + + +class PiTextContentV1(StrictDomainModel): + """One Pi text content block.""" + + type: Literal["text"] + text: ScalarString + + +class PiImageContentV1(StrictDomainModel): + """One Pi image content block.""" + + type: Literal["image"] + data: ScalarString + mimeType: ScalarString + + +class PiToolResultV1(StrictDomainModel): + """Provider-relevant fields from one Pi tool-result message.""" + + schema_version: Literal["openshell.pi-tool-result.v1"] + tool_call_id: ScalarString + tool_name: ScalarString + content: tuple[PiTextContentV1 | PiImageContentV1, ...] + is_error: bool + + @field_validator("content", mode="before") + @classmethod + def _content_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class PiAssistantToolCallV1(StrictDomainModel): + """One immutable Pi assistant tool call.""" + + id: ScalarString + name: ScalarString + arguments: dict[str, object] + + +class PiAssistantMessageV1(StrictDomainModel): + """Replaceable assistant text and immutable tool calls.""" + + schema_version: Literal["openshell.pi-assistant-message.v1"] + text: ScalarString + tool_calls: tuple[PiAssistantToolCallV1, ...] + + @field_validator("tool_calls", mode="before") + @classmethod + def _tool_calls_are_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class PiBashExecutionV1(StrictDomainModel): + """Replaceable bash output and immutable execution metadata.""" + + schema_version: Literal["openshell.pi-bash-execution.v1"] + command: ScalarString + output: ScalarString + exit_code: int | None + + +class UserContextEntryV1(StrictDomainModel): + """One ordered user entry sent to a provider.""" + + role: Literal["user"] + text: ScalarString + + +class ToolContextEntryV1(StrictDomainModel): + """One ordered tool entry sent to a provider.""" + + role: Literal["tool"] + tool_call_id: ScalarString + text: ScalarString + + +ContextEntryV1: TypeAlias = UserContextEntryV1 | ToolContextEntryV1 + + +class PiProviderContextV1(StrictDomainModel): + """Every provider-visible user and tool entry in order.""" + + schema_version: Literal["openshell.pi-provider-context.v1"] + entries: tuple[ContextEntryV1, ...] = Field(min_length=1) + + @field_validator("entries", mode="before") + @classmethod + def _entries_are_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +HarnessNative: TypeAlias = ( + PiMessageV1 + | PiToolResultV1 + | PiAssistantMessageV1 + | PiBashExecutionV1 + | PiProviderContextV1 +) +AttestedEntries: TypeAlias = tuple[ContextEntryV1, ...] + + +class PreparedHarnessRequest: + """Parsed Pi request plus its canonical Gate projection.""" + + def __init__( + self, + *, + native: HarnessNative, + projected_body: bytes, + original_body: bytes, + ) -> None: + self.native = native + self.projected_body = projected_body + self.original_body = original_body + + +class HarnessAdapter(Protocol): + """Fixed-authority translation for one registered harness hook.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: ... + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, HarnessNative]: ... + + def attestation_subject( + self, + prepared: PreparedHarnessRequest, + final: HarnessNative, + ) -> tuple[str, int] | None: ... + + +class _AppendHarnessAdapter: + def attestation_subject( + self, + prepared: PreparedHarnessRequest, + final: HarnessNative, + ) -> None: + return None + + +class PiMessageV1Adapter(_AppendHarnessAdapter): + """Strict adapter for one text-bearing Pi origin.""" + + def __init__(self, accepted_origin: PiMessageOrigin) -> None: + self._accepted_origin = accepted_origin + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_body( + request.request_body, timeout, accepted_origin=self._accepted_origin + ) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiMessageV1]: + updated = _parse_pi_body( + projected_body, timeout, accepted_origin=self._accepted_origin + ) + encoded = canonical_json_bytes(updated) + replacement = ( + None + if canonical_json_bytes(updated) == canonical_json_bytes(prepared.native) + else encoded + ) + return replacement, updated + + +class PiAssistantMessageV1Adapter(_AppendHarnessAdapter): + """Strict adapter for Pi assistant text and tool calls.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_assistant_message(request.request_body, timeout) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiAssistantMessageV1]: + updated = _parse_pi_assistant_message(projected_body, timeout) + if not isinstance(prepared.native, PiAssistantMessageV1): + raise AdmissionMutationError("assistant admission state is invalid") + if updated.tool_calls != prepared.native.tool_calls: + raise AdmissionMutationError("admission changed assistant tool calls") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, updated + + +class PiBashExecutionV1Adapter(_AppendHarnessAdapter): + """Strict adapter for Pi bash output.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_bash_execution(request.request_body, timeout) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiBashExecutionV1]: + updated = _parse_pi_bash_execution(projected_body, timeout) + if not isinstance(prepared.native, PiBashExecutionV1): + raise AdmissionMutationError("bash admission state is invalid") + immutable_before = (prepared.native.command, prepared.native.exit_code) + immutable_after = (updated.command, updated.exit_code) + if immutable_after != immutable_before: + raise AdmissionMutationError("admission changed bash execution metadata") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, updated + + +class PiToolResultV1Adapter(_AppendHarnessAdapter): + """Strict adapter for Pi tool-result content blocks.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_tool_result(request.request_body, timeout) + _tool_result_entry(native) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiToolResultV1]: + updated = _parse_pi_tool_result(projected_body, timeout) + if not isinstance(prepared.native, PiToolResultV1): + raise AdmissionMutationError("tool-result admission state is invalid") + immutable_before = ( + prepared.native.schema_version, + prepared.native.tool_call_id, + prepared.native.tool_name, + prepared.native.is_error, + ) + immutable_after = ( + updated.schema_version, + updated.tool_call_id, + updated.tool_name, + updated.is_error, + ) + if immutable_after != immutable_before: + raise AdmissionMutationError("admission changed tool-result metadata") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, updated + + +class PiProviderContextV1Adapter: + """Strict adapter for the complete ordered provider context.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_provider_context(request.request_body, timeout) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiProviderContextV1]: + updated = _parse_pi_provider_context(projected_body, timeout) + if not isinstance(prepared.native, PiProviderContextV1): + raise AdmissionMutationError("provider-context admission state is invalid") + before = tuple( + (entry.role, getattr(entry, "tool_call_id", None)) + for entry in prepared.native.entries + ) + after = tuple( + (entry.role, getattr(entry, "tool_call_id", None)) + for entry in updated.entries + ) + if after != before: + raise AdmissionMutationError("admission changed provider-context structure") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, updated + + def attestation_subject( + self, + prepared: PreparedHarnessRequest, + final: HarnessNative, + ) -> tuple[str, int]: + if not isinstance(final, PiProviderContextV1): + raise AdmissionMutationError("provider-context admission state is invalid") + return context_entries_subject(final.entries) + + +class HarnessAdapterRegistry: + """Small explicit registry for supported harness admission shapes.""" + + def __init__(self) -> None: + self._adapters: dict[tuple[str, str, str], HarnessAdapter] = {} + + def register( + self, + harness: str, + hook: AdmissionHook, + schema_version: str, + adapter: HarnessAdapter, + ) -> None: + key = (harness, hook.value, schema_version) + if key in self._adapters: + raise ValueError("harness adapter is already registered") + self._adapters[key] = adapter + + def resolve(self, context: HarnessAdmissionContext) -> HarnessAdapter: + key = (context.harness, context.hook.value, context.schema_version) + try: + return self._adapters[key] + except KeyError: + raise AdmissionShapeError( + "harness admission shape is unsupported" + ) from None + + @property + def bindings(self) -> tuple[tuple[str, str, str], ...]: + """Return registered harness, hook, and schema bindings.""" + return tuple(self._adapters) + + +class _ProviderTextBlock(StrictDomainModel): + type: Literal["text"] + text: ScalarString + + +class _ProviderFunction(StrictDomainModel): + name: ScalarString + arguments: ScalarString + + +class _ProviderToolCall(StrictDomainModel): + id: ScalarString + type: Literal["function"] + function: _ProviderFunction + + +class _ProviderMessage(StrictDomainModel): + role: Literal["system", "developer", "user", "assistant", "tool"] + content: ScalarString | tuple[_ProviderTextBlock, ...] | None = None + name: ScalarString | None = None + tool_call_id: ScalarString | None = None + tool_calls: tuple[_ProviderToolCall, ...] = () + reasoning_content: ScalarString | None = None + + @field_validator("content", "tool_calls", mode="before") + @classmethod + def _provider_sequences_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + @model_validator(mode="after") + def _optional_fields_have_one_representation(self) -> _ProviderMessage: + if "content" not in self.model_fields_set: + raise ValueError("provider messages must include content") + if "name" in self.model_fields_set and self.name is None: + raise ValueError("provider message name cannot be null") + if "tool_call_id" in self.model_fields_set and self.tool_call_id is None: + raise ValueError("provider tool-call ID cannot be null") + if "tool_calls" in self.model_fields_set and not self.tool_calls: + raise ValueError("provider tool calls cannot be empty") + if ( + "reasoning_content" in self.model_fields_set + and self.reasoning_content is None + ): + raise ValueError("provider reasoning content cannot be null") + return self + + +class _ProviderFunctionDefinition(StrictDomainModel): + name: ScalarString + description: ScalarString + parameters: dict[str, object] + strict: bool | None = None + + @model_validator(mode="after") + def _optional_strict_is_not_null(self) -> _ProviderFunctionDefinition: + if "strict" in self.model_fields_set and self.strict is None: + raise ValueError("provider function strict cannot be null") + return self + + +class _ProviderTool(StrictDomainModel): + type: Literal["function"] + function: _ProviderFunctionDefinition + + +class _ProviderNamedChoiceFunction(StrictDomainModel): + name: ScalarString + + +class _ProviderNamedToolChoice(StrictDomainModel): + type: Literal["function"] + function: _ProviderNamedChoiceFunction + + +class _ProviderStreamOptions(StrictDomainModel): + include_usage: Literal[True] + + +class _ProviderRequest(StrictDomainModel): + model: ScalarString + messages: tuple[_ProviderMessage, ...] + tools: tuple[_ProviderTool, ...] = () + tool_choice: Literal["auto", "none", "required"] | _ProviderNamedToolChoice = "auto" + temperature: int | float | None = Field(default=None, allow_inf_nan=False) + max_completion_tokens: int | None = Field(default=None, ge=1) + max_tokens: int | None = Field(default=None, ge=1) + stream: Literal[True] + stream_options: _ProviderStreamOptions + store: Literal[False] | None = None + prompt_cache_key: ScalarString | None = None + prompt_cache_retention: Literal["24h"] | None = None + reasoning_effort: ScalarString | None = None + enable_thinking: bool | None = None + + @field_validator("messages", "tools", mode="before") + @classmethod + def _provider_collections_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list | tuple) else value + + @model_validator(mode="after") + def _compatibility_fields_have_one_representation(self) -> _ProviderRequest: + if (self.max_completion_tokens is None) == (self.max_tokens is None): + raise ValueError("provider request requires exactly one max-token field") + for field_name in ("store", "enable_thinking"): + if ( + field_name in self.model_fields_set + and getattr(self, field_name) is None + ): + raise ValueError(f"provider request {field_name} cannot be null") + return self + + @property + def output_token_limit(self) -> int: + value = self.max_completion_tokens or self.max_tokens + if value is None: + raise ValueError("provider request has no max-token field") + return value + + +class _ResponsesInputText(StrictDomainModel): + type: Literal["input_text"] + text: ScalarString + + +class _ResponsesOutputText(StrictDomainModel): + type: Literal["output_text"] + text: ScalarString + annotations: tuple[object, ...] + + @field_validator("annotations", mode="before") + @classmethod + def _annotations_are_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class _ResponsesInputMessage(StrictDomainModel): + role: Literal["system", "developer", "user"] + content: ScalarString | tuple[_ResponsesInputText, ...] + type: Literal["message"] | None = None + + @field_validator("content", mode="before") + @classmethod + def _content_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + @model_validator(mode="after") + def _optional_type_is_not_null(self) -> _ResponsesInputMessage: + if "type" in self.model_fields_set and self.type is None: + raise ValueError("Responses input message type cannot be null") + return self + + +class _ResponsesAssistantMessage(StrictDomainModel): + type: Literal["message"] + role: Literal["assistant"] + content: tuple[_ResponsesOutputText, ...] + status: Literal["completed"] + id: ScalarString + phase: Literal["commentary", "final_answer"] | None = None + + @field_validator("content", mode="before") + @classmethod + def _content_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class _ResponsesFunctionCall(StrictDomainModel): + type: Literal["function_call"] + call_id: ScalarString + name: ScalarString + arguments: ScalarString + id: ScalarString | None = None + namespace: ScalarString | None = None + + +class _ResponsesFunctionCallOutput(StrictDomainModel): + type: Literal["function_call_output"] + call_id: ScalarString + output: ScalarString | tuple[_ResponsesInputText, ...] + + @field_validator("output", mode="before") + @classmethod + def _output_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class _ResponsesReasoningSummary(StrictDomainModel): + type: Literal["summary_text"] + text: ScalarString + + +class _ResponsesReasoningContent(StrictDomainModel): + type: Literal["reasoning_text"] + text: ScalarString + + +class _ResponsesReasoning(StrictDomainModel): + type: Literal["reasoning"] + id: ScalarString + summary: tuple[_ResponsesReasoningSummary, ...] + content: tuple[_ResponsesReasoningContent, ...] | None = None + encrypted_content: ScalarString | None = None + status: Literal["in_progress", "completed", "incomplete"] | None = None + + @field_validator("summary", "content", mode="before") + @classmethod + def _sequences_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +_ResponsesInputItem: TypeAlias = ( + _ResponsesInputMessage + | _ResponsesAssistantMessage + | _ResponsesFunctionCall + | _ResponsesFunctionCallOutput + | _ResponsesReasoning +) + + +class _ResponsesTool(StrictDomainModel): + type: Literal["function"] + name: ScalarString + description: ScalarString + parameters: dict[str, object] + strict: bool | None = None + + +class _ResponsesNamedToolChoice(StrictDomainModel): + type: Literal["function"] + name: ScalarString + + +class _ResponsesReasoningOptions(StrictDomainModel): + effort: ScalarString + summary: Literal["auto", "detailed", "concise"] | None = None + + +class _ResponsesPromptCacheOptions(StrictDomainModel): + mode: Literal["explicit"] + + +class _ResponsesRequest(StrictDomainModel): + model: ScalarString + input: tuple[_ResponsesInputItem, ...] + stream: Literal[True] + store: Literal[False] + max_output_tokens: int = Field(ge=1) + tools: tuple[_ResponsesTool, ...] = () + tool_choice: Literal["auto", "none", "required"] | _ResponsesNamedToolChoice = ( + "auto" + ) + temperature: int | float | None = Field(default=None, allow_inf_nan=False) + prompt_cache_key: ScalarString | None = None + prompt_cache_retention: Literal["24h"] | None = None + prompt_cache_options: _ResponsesPromptCacheOptions | None = None + reasoning: _ResponsesReasoningOptions | None = None + include: tuple[Literal["reasoning.encrypted_content"], ...] = () + service_tier: Literal["auto", "default", "flex", "scale", "priority"] | None = None + + @field_validator("input", "tools", "include", mode="before") + @classmethod + def _collections_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list | tuple) else value + + +class ProviderRequestAdapter(Protocol): + """Validate and project a provider request for rendered-prompt extraction.""" + + schema_version: str + + def canonicalize( + self, request: HttpRequest, timeout: Timeout + ) -> ModelRequestV1: ... + + def attested_entries( + self, request: HttpRequest, timeout: Timeout + ) -> AttestedEntries: ... + + +class OpenAIChatCompletionsV1Adapter: + """Pinned OpenAI-compatible Chat Completions request adapter.""" + + schema_version = "openai.chat-completions.v1" + + def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1: + if request.target.method.upper() != "POST": + raise ProviderShapeError("provider request method is unsupported") + content_types = [ + header.value.strip().lower() + for header in request.headers + if header.name.lower() == "content-type" + ] + if content_types != ["application/json"]: + raise ProviderShapeError("provider request requires one JSON content type") + if any(header.name.lower() == "content-encoding" for header in request.headers): + raise ProviderShapeError("provider request content encoding is unsupported") + value = _load_json(request.body, ProviderShapeError, timeout) + try: + provider = _PROVIDER_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise ProviderShapeError("provider request body is unsupported") from None + if not isinstance(provider, _ProviderRequest): + raise ProviderShapeError("provider request body is unsupported") + messages = tuple( + _provider_message_to_canonical(item) for item in provider.messages + ) + tools = tuple( + CanonicalToolV1( + name=item.function.name, + description=item.function.description, + input_schema=item.function.parameters, + ) + for item in provider.tools + ) + if isinstance(provider.tool_choice, str): + tool_choice = CanonicalToolChoiceV1(mode=provider.tool_choice) + else: + tool_choice = CanonicalToolChoiceV1( + mode="function", + function_name=provider.tool_choice.function.name, + ) + return ModelRequestV1( + model=provider.model, + messages=messages, + tools=tools, + tool_choice=tool_choice, + generation=CanonicalGenerationV1( + temperature=provider.temperature, + max_tokens=provider.output_token_limit, + ), + ) + + def attested_entries( + self, request: HttpRequest, timeout: Timeout + ) -> AttestedEntries: + """Extract every user and tool entry in provider order.""" + canonical = self.canonicalize(request, timeout) + entries: list[ContextEntryV1] = [] + for message in canonical.messages: + if message.role is CanonicalRole.USER and message.content is not None: + entries.append(UserContextEntryV1(role="user", text=message.content)) + if message.role is CanonicalRole.TOOL and message.content is not None: + if message.tool_call_id is None: + raise ProviderShapeError("provider tool result has no call ID") + entries.append( + ToolContextEntryV1( + role="tool", + tool_call_id=message.tool_call_id, + text=message.content, + ) + ) + if not entries: + raise ProviderShapeError("provider request has no attested context entries") + return tuple(entries) + + +class OpenAIResponsesV1Adapter: + """Pinned OpenAI-compatible Responses request adapter.""" + + schema_version = "openai.responses.v1" + + def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1: + provider = self._parse(request, timeout) + messages: list[CanonicalMessageV1] = [] + for item in provider.input: + if isinstance(item, _ResponsesInputMessage): + messages.append( + CanonicalMessageV1( + role=CanonicalRole(item.role), + content=_responses_text(item.content), + ) + ) + elif isinstance(item, _ResponsesAssistantMessage): + messages.append( + CanonicalMessageV1( + role=CanonicalRole.ASSISTANT, + content="\n".join(block.text for block in item.content), + ) + ) + elif isinstance(item, _ResponsesFunctionCall): + messages.append( + CanonicalMessageV1( + role=CanonicalRole.ASSISTANT, + content=None, + tool_calls=( + CanonicalFunctionCallV1( + id=item.call_id, + name=item.name, + arguments=item.arguments, + ), + ), + ) + ) + elif isinstance(item, _ResponsesFunctionCallOutput): + messages.append(_responses_tool_result(item)) + tools = tuple( + CanonicalToolV1( + name=item.name, + description=item.description, + input_schema=item.parameters, + ) + for item in provider.tools + ) + if isinstance(provider.tool_choice, str): + tool_choice = CanonicalToolChoiceV1(mode=provider.tool_choice) + else: + tool_choice = CanonicalToolChoiceV1( + mode="function", function_name=provider.tool_choice.name + ) + return ModelRequestV1( + model=provider.model, + messages=tuple(messages), + tools=tools, + tool_choice=tool_choice, + generation=CanonicalGenerationV1( + temperature=provider.temperature, + max_tokens=provider.max_output_tokens, + ), + ) + + def attested_entries( + self, request: HttpRequest, timeout: Timeout + ) -> AttestedEntries: + """Extract every user and function-call output entry in provider order.""" + provider = self._parse(request, timeout) + entries: list[ContextEntryV1] = [] + for item in provider.input: + if isinstance(item, _ResponsesInputMessage) and item.role == "user": + entries.append( + UserContextEntryV1(role="user", text=_responses_text(item.content)) + ) + if isinstance(item, _ResponsesFunctionCallOutput): + message = _responses_tool_result(item) + if message.tool_call_id is None or message.content is None: + raise ProviderShapeError("provider tool result is incomplete") + entries.append( + ToolContextEntryV1( + role="tool", + tool_call_id=message.tool_call_id, + text=message.content, + ) + ) + if not entries: + raise ProviderShapeError("provider request has no attested context entries") + return tuple(entries) + + def _parse(self, request: HttpRequest, timeout: Timeout) -> _ResponsesRequest: + _validate_json_request(request) + value = _load_json(request.body, ProviderShapeError, timeout) + try: + provider = _RESPONSES_PROVIDER_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise ProviderShapeError("provider request body is unsupported") from None + if not isinstance(provider, _ResponsesRequest): + raise ProviderShapeError("provider request body is unsupported") + return provider + + +class ProviderAdapterRegistry: + """Explicit versioned provider-adapter registry.""" + + def __init__(self) -> None: + self._adapters: dict[str, ProviderRequestAdapter] = {} + + def register(self, adapter: ProviderRequestAdapter) -> None: + if adapter.schema_version in self._adapters: + raise ValueError("provider adapter is already registered") + self._adapters[adapter.schema_version] = adapter + + def resolve(self, schema_version: str) -> ProviderRequestAdapter: + try: + return self._adapters[schema_version] + except KeyError: + raise ProviderShapeError("provider adapter is unsupported") from None + + def resolve_request( + self, request: HttpRequest, timeout: Timeout + ) -> ProviderRequestAdapter: + """Select the adapter from the mutually exclusive top-level request shape.""" + value = _load_json(request.body, ProviderShapeError, timeout) + if not isinstance(value, dict): + raise ProviderShapeError("provider request body is unsupported") + if "messages" in value and "input" not in value: + return self.resolve(OpenAIChatCompletionsV1Adapter.schema_version) + if "input" in value and "messages" not in value: + return self.resolve(OpenAIResponsesV1Adapter.schema_version) + raise ProviderShapeError("provider request body is unsupported") + + +def create_pi_adapter_registry() -> HarnessAdapterRegistry: + """Return the built-in Pi v1 admission registry.""" + registry = HarnessAdapterRegistry() + for hook, origin in ( + (AdmissionHook.USER_MESSAGE, "user"), + (AdmissionHook.COMPACTION_SUMMARY, "compaction_summary"), + (AdmissionHook.BRANCH_SUMMARY, "branch_summary"), + (AdmissionHook.EXTENSION_MESSAGE, "extension_message"), + ): + registry.register( + "pi", + hook, + "openshell.pi-message.v1", + PiMessageV1Adapter(origin), + ) + registry.register( + "pi", + AdmissionHook.TOOL_RESULT, + "openshell.pi-tool-result.v1", + PiToolResultV1Adapter(), + ) + registry.register( + "pi", + AdmissionHook.ASSISTANT_MESSAGE, + "openshell.pi-assistant-message.v1", + PiAssistantMessageV1Adapter(), + ) + registry.register( + "pi", + AdmissionHook.BASH_EXECUTION, + "openshell.pi-bash-execution.v1", + PiBashExecutionV1Adapter(), + ) + registry.register( + "pi", + AdmissionHook.PROVIDER_CONTEXT, + "openshell.pi-provider-context.v1", + PiProviderContextV1Adapter(), + ) + return registry + + +def create_provider_adapter_registry() -> ProviderAdapterRegistry: + """Return the built-in OpenAI provider-request registry.""" + registry = ProviderAdapterRegistry() + registry.register(OpenAIChatCompletionsV1Adapter()) + registry.register(OpenAIResponsesV1Adapter()) + return registry + + +def _parse_pi_body( + body: bytes, timeout: Timeout, *, accepted_origin: PiMessageOrigin = "user" +) -> PiMessageV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi request body is unsupported") from None + if not isinstance(parsed, PiMessageV1): + raise AdmissionShapeError("Pi request body is unsupported") + if parsed.origin != accepted_origin: + raise AdmissionShapeError("Pi message origin is unsupported") + if canonical_json_bytes(parsed) != body: + raise AdmissionShapeError("Pi request body is not canonical JSON") + return parsed + + +def _parse_pi_assistant_message(body: bytes, timeout: Timeout) -> PiAssistantMessageV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_ASSISTANT_MESSAGE_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi assistant-message body is unsupported") from None + return parsed + + +def _parse_pi_bash_execution(body: bytes, timeout: Timeout) -> PiBashExecutionV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_BASH_EXECUTION_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi bash-execution body is unsupported") from None + if canonical_json_bytes(parsed) != body: + raise AdmissionShapeError("Pi bash-execution body is not canonical JSON") + return parsed + + +def _parse_pi_tool_result(body: bytes, timeout: Timeout) -> PiToolResultV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_TOOL_RESULT_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi tool-result body is unsupported") from None + if not isinstance(parsed, PiToolResultV1): + raise AdmissionShapeError("Pi tool-result body is unsupported") + return parsed + + +def _parse_pi_provider_context(body: bytes, timeout: Timeout) -> PiProviderContextV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_PROVIDER_CONTEXT_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi provider-context body is unsupported") from None + if canonical_json_bytes(parsed) != body: + raise AdmissionShapeError("Pi provider-context body is not canonical JSON") + return parsed + + +def _tool_result_entry(result: PiToolResultV1) -> ToolContextEntryV1: + if any(block.type == "image" for block in result.content): + raise AdmissionShapeError("Pi tool-result images are unsupported") + text = "\n".join( + block.text for block in result.content if isinstance(block, PiTextContentV1) + ) + return ToolContextEntryV1( + role="tool", + text=text or "(no tool output)", + tool_call_id=_provider_tool_call_id(result.tool_call_id), + ) + + +def context_entries_subject(entries: AttestedEntries) -> tuple[str, int]: + """Return the v2 hash and count for one ordered entry list.""" + body = json.dumps( + [entry.model_dump(mode="json") for entry in entries], + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(body).hexdigest(), len(entries) + + +def _validate_json_request(request: HttpRequest) -> None: + if request.target.method.upper() != "POST": + raise ProviderShapeError("provider request method is unsupported") + content_types = [ + header.value.strip().lower() + for header in request.headers + if header.name.lower() == "content-type" + ] + if content_types != ["application/json"]: + raise ProviderShapeError("provider request requires one JSON content type") + if any(header.name.lower() == "content-encoding" for header in request.headers): + raise ProviderShapeError("provider request content encoding is unsupported") + + +def _responses_text(value: ScalarString | tuple[_ResponsesInputText, ...]) -> str: + if isinstance(value, str): + return value + if not value: + raise ProviderShapeError("provider message content cannot be empty") + return "\n".join(block.text for block in value) + + +def _responses_tool_result( + item: _ResponsesFunctionCallOutput, +) -> CanonicalMessageV1: + return CanonicalMessageV1( + role=CanonicalRole.TOOL, + content=_responses_text(item.output), + tool_call_id=_provider_tool_call_id(item.call_id), + ) + + +def _provider_tool_call_id(value: str) -> str: + return value.split("|", 1)[0] + + +def _load_json(body: bytes, error_type: type[ValueError], timeout: Timeout) -> object: + try: + JsonDocument.parse(body, timeout=timeout) + except (BodyFormatError, GateInputError): + raise error_type("request body is not canonical JSON") from None + try: + text = body.decode("utf-8", errors="strict") + return json.loads(text, object_pairs_hook=_unique_object) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError): + raise error_type("request body is not canonical JSON") from None + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + output: dict[str, object] = {} + for key, value in pairs: + if key in output: + raise ValueError("duplicate JSON object key") + output[key] = value + return output + + +def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1: + if isinstance(item.content, tuple): + content = "\n".join(block.text for block in item.content) + else: + content = item.content + return CanonicalMessageV1( + role=CanonicalRole(item.role), + content=content, + name=item.name, + tool_call_id=( + _provider_tool_call_id(item.tool_call_id) + if item.tool_call_id is not None + else None + ), + tool_calls=tuple( + CanonicalFunctionCallV1( + id=call.id, + name=call.function.name, + arguments=call.function.arguments, + ) + for call in item.tool_calls + ), + ) + + +_PI_ADAPTER = TypeAdapter(PiMessageV1) +_PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) +_PI_ASSISTANT_MESSAGE_ADAPTER = TypeAdapter(PiAssistantMessageV1) +_PI_BASH_EXECUTION_ADAPTER = TypeAdapter(PiBashExecutionV1) +_PI_PROVIDER_CONTEXT_ADAPTER = TypeAdapter(PiProviderContextV1) +_PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) +_RESPONSES_PROVIDER_ADAPTER = TypeAdapter(_ResponsesRequest) + + +__all__ = [ + "AdmissionMutationError", + "AdmissionShapeError", + "AttestedEntries", + "ContextEntryV1", + "HarnessAdapter", + "HarnessAdapterRegistry", + "OpenAIChatCompletionsV1Adapter", + "OpenAIResponsesV1Adapter", + "PiMessageV1", + "PiImageContentV1", + "PiAssistantMessageV1", + "PiAssistantMessageV1Adapter", + "PiAssistantToolCallV1", + "PiBashExecutionV1", + "PiBashExecutionV1Adapter", + "PiTextContentV1", + "PiToolResultV1", + "PiToolResultV1Adapter", + "PiMessageV1Adapter", + "PiProviderContextV1", + "PiProviderContextV1Adapter", + "PreparedHarnessRequest", + "ProviderAdapterRegistry", + "ProviderRequestAdapter", + "ProviderShapeError", + "ToolContextEntryV1", + "UserContextEntryV1", + "context_entries_subject", + "create_pi_adapter_registry", + "create_provider_adapter_registry", +] diff --git a/projects/egress-gate/src/egress_gate/admission/canonical.py b/projects/egress-gate/src/egress_gate/admission/canonical.py new file mode 100644 index 00000000..cd5d08f0 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/canonical.py @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Strict canonical model-request schema and encoding.""" + +from __future__ import annotations + +import json +import math +from enum import StrEnum +from typing import Literal + +from pydantic import Field, field_validator, model_validator + +from egress_gate.base import StrictDomainModel +from egress_gate.string_validators import ScalarString + + +class CanonicalRole(StrEnum): + """Roles supported by the pinned provider schema.""" + + SYSTEM = "system" + DEVELOPER = "developer" + USER = "user" + ASSISTANT = "assistant" + TOOL = "tool" + + +class CanonicalFunctionCallV1(StrictDomainModel): + """One model-produced function call without lossy argument parsing.""" + + id: ScalarString + name: ScalarString + arguments: ScalarString + + +class CanonicalMessageV1(StrictDomainModel): + """One ordered, provider-visible message.""" + + role: CanonicalRole + content: ScalarString | None + name: ScalarString | None = None + tool_call_id: ScalarString | None = None + tool_calls: tuple[CanonicalFunctionCallV1, ...] = () + + @model_validator(mode="after") + def _role_fields_are_consistent(self) -> CanonicalMessageV1: + if self.role is CanonicalRole.TOOL: + if self.content is None or self.tool_call_id is None or self.tool_calls: + raise ValueError("tool messages require content and tool_call_id") + elif self.tool_call_id is not None: + raise ValueError("only tool messages may carry tool_call_id") + if self.tool_calls and self.role is not CanonicalRole.ASSISTANT: + raise ValueError("only assistant messages may carry tool calls") + if self.content is None and not self.tool_calls: + raise ValueError("messages require content or tool calls") + return self + + +class CanonicalToolV1(StrictDomainModel): + """One complete function-tool definition.""" + + name: ScalarString + description: ScalarString + input_schema: dict[str, object] + + @field_validator("input_schema") + @classmethod + def _schema_is_canonical_json(cls, value: dict[str, object]) -> dict[str, object]: + _validate_json_value(value) + return value + + +class CanonicalToolChoiceV1(StrictDomainModel): + """Pinned OpenAI tool-selection semantics.""" + + mode: Literal["auto", "none", "required", "function"] + function_name: ScalarString | None = None + + @model_validator(mode="after") + def _function_name_matches_mode(self) -> CanonicalToolChoiceV1: + if (self.mode == "function") != (self.function_name is not None): + raise ValueError("function tool choice requires exactly one name") + return self + + +class CanonicalGenerationV1(StrictDomainModel): + """Semantic generation fields accepted from the pinned Pi serializer.""" + + temperature: float | None = Field(default=None, allow_inf_nan=False) + max_tokens: int = Field(ge=1) + + @field_validator("temperature", mode="before") + @classmethod + def _normalize_temperature(cls, value: object) -> float | None: + if value is None: + return value + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError("temperature must be numeric") + normalized = float(value) + return 0.0 if normalized == 0 else normalized + + +class ModelRequestV1(StrictDomainModel): + """Validated semantic view of one supported provider request.""" + + schema_version: Literal["model-request.v1"] = "model-request.v1" + model: ScalarString + messages: tuple[CanonicalMessageV1, ...] + tools: tuple[CanonicalToolV1, ...] + tool_choice: CanonicalToolChoiceV1 + generation: CanonicalGenerationV1 + + +def canonical_json_bytes(value: StrictDomainModel) -> bytes: + """Encode a validated model with stable UTF-8 JSON semantics.""" + return json.dumps( + value.model_dump(mode="json"), + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def _validate_json_value(value: object) -> None: + if value is None or isinstance(value, str | bool | int): + return + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("JSON numbers must be finite") + return + if isinstance(value, list): + for item in value: + _validate_json_value(item) + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + raise ValueError("JSON object keys must be strings") + key.encode("utf-8", errors="strict") + _validate_json_value(item) + return + raise ValueError("value is not canonical JSON") + + +__all__ = [ + "CanonicalFunctionCallV1", + "CanonicalGenerationV1", + "CanonicalMessageV1", + "CanonicalRole", + "CanonicalToolChoiceV1", + "CanonicalToolV1", + "ModelRequestV1", + "canonical_json_bytes", +] diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py new file mode 100644 index 00000000..062d19f7 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Public, transport-neutral models for harness admission.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import Field, model_validator + +from egress_gate.base import StrictDomainModel +from egress_gate.constants import MAX_PROTO_FINDING_GROUPS +from egress_gate.request import HttpTarget +from egress_gate.result import ReasonCode, SourcedFinding +from egress_gate.string_validators import BoundedMetadataString, ScalarString + +MAX_ADMISSION_BODY_BYTES = 4 * 1024 * 1024 +PI_HARNESS_VERSION = "sdk-v1" + + +class AdmissionHook(StrEnum): + """Supported harness admission boundaries.""" + + USER_MESSAGE = "user_message" + TOOL_RESULT = "tool_result" + ASSISTANT_MESSAGE = "assistant_message" + COMPACTION_SUMMARY = "compaction_summary" + BRANCH_SUMMARY = "branch_summary" + EXTENSION_MESSAGE = "extension_message" + BASH_EXECUTION = "bash_execution" + PROVIDER_CONTEXT = "provider_context" + + +class AdmissionDecision(StrEnum): + """Disposition of a harness request.""" + + ALLOW = "allow" + REPLACE = "replace" + DENY = "deny" + + +class AdmissionProvenance(StrictDomainModel): + """Request-local correlation assertions for one context addition.""" + + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + + +class HarnessAdmissionRequest(StrictDomainModel): + """One complete harness-native context addition.""" + + request_body: bytes = Field(max_length=MAX_ADMISSION_BODY_BYTES, repr=False) + provenance: AdmissionProvenance + + +class HarnessAdmissionContext(StrictDomainModel): + """Trusted admission context stamped outside the workload.""" + + request_id: BoundedMetadataString + sandbox_id: BoundedMetadataString + middleware_name: BoundedMetadataString + harness: ScalarString + harness_version: Literal["sdk-v1"] + hook: AdmissionHook + schema_version: ScalarString + provider_target: HttpTarget + provider_adapter_schema: Literal["openai.request.v1"] + + +class HarnessAdmissionResult(StrictDomainModel): + """Atomic policy decision returned to a managed harness.""" + + hook: AdmissionHook + decision: AdmissionDecision + replacement_body: bytes | None = Field( + default=None, + max_length=MAX_ADMISSION_BODY_BYTES, + repr=False, + ) + attestation: bytes | None = Field( + default=None, + min_length=1, + max_length=8 * 1024, + repr=False, + ) + findings: tuple[SourcedFinding, ...] = Field( + default=(), max_length=MAX_PROTO_FINDING_GROUPS + ) + reason_code: ReasonCode | None = None + policy_fingerprint: ScalarString + + @model_validator(mode="after") + def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: + if self.decision is AdmissionDecision.DENY: + if self.reason_code is None: + raise ValueError("denial requires a reason code") + if self.replacement_body is not None or self.attestation is not None: + raise ValueError("denial cannot carry a replacement or attestation") + else: + if self.reason_code is not None: + raise ValueError("allow decisions cannot carry a reason code") + if ( + self.decision is AdmissionDecision.REPLACE + and self.replacement_body is None + ): + raise ValueError("replace decisions require a replacement body") + if ( + self.decision is AdmissionDecision.ALLOW + and self.replacement_body is not None + ): + raise ValueError("allow decisions cannot carry a replacement body") + if (self.hook is AdmissionHook.PROVIDER_CONTEXT) != ( + self.attestation is not None + ): + raise ValueError( + "only provider-context admission carries an attestation" + ) + return self + + +__all__ = [ + "AdmissionDecision", + "AdmissionHook", + "AdmissionProvenance", + "HarnessAdmissionContext", + "HarnessAdmissionRequest", + "HarnessAdmissionResult", + "MAX_ADMISSION_BODY_BYTES", + "PI_HARNESS_VERSION", +] diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py new file mode 100644 index 00000000..9a978f63 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -0,0 +1,253 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Harness-admission orchestration and attested network egress.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import ValidationError + +from egress_gate.admission.adapters import ( + AdmissionMutationError, + AdmissionShapeError, + HarnessAdapterRegistry, + ProviderAdapterRegistry, + ProviderShapeError, + context_entries_subject, +) +from egress_gate.admission.models import ( + MAX_ADMISSION_BODY_BYTES, + AdmissionDecision, + AdmissionHook, + HarnessAdmissionContext, + HarnessAdmissionRequest, + HarnessAdmissionResult, +) +from egress_gate.admission.receipts import ReceiptAuthority, ReceiptVerificationError +from egress_gate.errors import EgressGateError, GateError, TimeoutExpiredError +from egress_gate.request import ( + EnforcementPoint, + HarnessAdmissionMetadata, + HttpRequest, + RequestContext, +) +from egress_gate.request_processor import RequestProcessor, apply_request_mutations +from egress_gate.result import ( + DecisionSourceKind, + EgressDecision, + EgressResult, + GateDecisionSource, +) +from egress_gate.timeout import Timeout + +RECEIPT_HEADER = "x-openshell-middleware-egress-receipt" + + +class HarnessAdmissionProcessor: + """Apply the configured Gate pipeline through one registered harness adapter.""" + + def __init__( + self, + request_processor: RequestProcessor, + adapters: HarnessAdapterRegistry, + receipt_authority: ReceiptAuthority, + ) -> None: + fingerprint = request_processor.policy_fingerprint + if not fingerprint: + raise ValueError("admission requires a policy fingerprint") + self._request_processor = request_processor + self._adapters = adapters + self._receipt_authority = receipt_authority + self._policy_fingerprint = fingerprint + + def process( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + *, + timeout: Timeout, + ) -> HarnessAdmissionResult: + """Return an explicit allow, replacement, or fail-closed denial.""" + try: + adapter = self._adapters.resolve(context) + prepared = adapter.prepare(request, context, timeout) + projected = HttpRequest( + context=RequestContext( + request_id=context.request_id, + sandbox_id=context.sandbox_id, + enforcement_point=EnforcementPoint.HARNESS_ADMISSION, + harness_admission=HarnessAdmissionMetadata( + harness=context.harness, + harness_version=context.harness_version, + hook=context.hook.value, + schema_version=context.schema_version, + ), + ), + target=context.provider_target, + headers=(), + body=prepared.projected_body, + ) + gate_result = self._request_processor.process(projected, timeout=timeout) + timeout.raise_if_expired() + if gate_result.decision is EgressDecision.DENY: + return HarnessAdmissionResult( + hook=context.hook, + decision=AdmissionDecision.DENY, + findings=gate_result.findings, + reason_code=gate_result.reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + if gate_result.request_mutations.header_mutations: + raise AdmissionMutationError("admission cannot mutate HTTP headers") + final_request = apply_request_mutations( + projected, gate_result.request_mutations + ) + replacement, final = adapter.validate_result( + prepared, final_request.body, context, timeout + ) + if replacement is not None and len(replacement) > MAX_ADMISSION_BODY_BYTES: + raise AdmissionMutationError("admission replacement body is too large") + timeout.raise_if_expired() + subject = adapter.attestation_subject(prepared, final) + attestation = None + if subject is not None: + attestation = self._receipt_authority.issue_attestation( + *subject, + context, + request.provenance, + policy_fingerprint=self._policy_fingerprint, + ) + timeout.raise_if_expired() + return HarnessAdmissionResult( + hook=context.hook, + decision=( + AdmissionDecision.REPLACE + if replacement is not None + else AdmissionDecision.ALLOW + ), + replacement_body=replacement, + attestation=attestation, + findings=gate_result.findings, + policy_fingerprint=self._policy_fingerprint, + ) + except (AdmissionShapeError, AdmissionMutationError, ValidationError): + return self._deny("admission_contract_invalid", context.hook) + except TimeoutExpiredError: + return self._deny("admission_unavailable", context.hook) + except (EgressGateError, GateError, ValueError): + return self._deny("admission_unavailable", context.hook) + except Exception: + return self._deny("admission_unavailable", context.hook) + + def _deny(self, reason_code: str, hook: AdmissionHook) -> HarnessAdmissionResult: + return HarnessAdmissionResult( + hook=hook, + decision=AdmissionDecision.DENY, + reason_code=reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + + +class AttestedEgressProcessor: + """Verify trusted agent attestation and reject context divergence.""" + + def __init__( + self, + request_processor: RequestProcessor, + provider_adapters: ProviderAdapterRegistry, + receipt_authority: ReceiptAuthority, + *, + middleware_name: str, + harness_version: Literal["sdk-v1"], + ) -> None: + fingerprint = request_processor.policy_fingerprint + if not fingerprint: + raise ValueError("attested egress requires a policy fingerprint") + self._request_processor = request_processor + self._provider_adapters = provider_adapters + self._receipt_authority = receipt_authority + self._middleware_name = middleware_name + self._harness_version = harness_version + self._policy_fingerprint = fingerprint + + def process( + self, + request: HttpRequest, + *, + agent_attestation: bytes, + timeout: Timeout, + ) -> EgressResult: + """Deny any unattested or semantically changed provider request.""" + if request.context.enforcement_point is not EnforcementPoint.NETWORK_EGRESS: + return self._deny("network_context_invalid") + if any(header.name.lower() == RECEIPT_HEADER for header in request.headers): + return self._deny("reserved_header_present") + if not agent_attestation: + return self._deny("attestation_missing") + try: + adapter = self._provider_adapters.resolve_request(request, timeout) + entries = adapter.attested_entries(request, timeout) + subject_hash, entry_count = context_entries_subject(entries) + timeout.raise_if_expired() + context = HarnessAdmissionContext( + request_id=request.context.request_id, + sandbox_id=request.context.sandbox_id, + middleware_name=self._middleware_name, + harness="pi", + harness_version=self._harness_version, + hook=AdmissionHook.PROVIDER_CONTEXT, + schema_version="openshell.pi-provider-context.v1", + provider_target=request.target, + provider_adapter_schema="openai.request.v1", + ) + self._receipt_authority.verify_attestation( + agent_attestation, + subject_hash, + entry_count, + context, + policy_fingerprint=self._policy_fingerprint, + ) + timeout.raise_if_expired() + gate_result = self._request_processor.process(request, timeout=timeout) + timeout.raise_if_expired() + if gate_result.decision is EgressDecision.DENY: + return gate_result + final_request = apply_request_mutations( + request, gate_result.request_mutations + ) + final_entries = adapter.attested_entries(final_request, timeout) + if final_entries != entries: + return self._deny("semantic_mutation_denied") + timeout.raise_if_expired() + return gate_result + except ReceiptVerificationError as error: + return self._deny(error.reason_code) + except TimeoutExpiredError: + return self._deny("egress_verification_failed") + except (ProviderShapeError, ValidationError): + return self._deny("provider_shape_unsupported") + except (EgressGateError, GateError, ValueError): + return self._deny("egress_verification_failed") + except Exception: + return self._deny("egress_verification_failed") + + def _deny(self, reason_code: str) -> EgressResult: + return EgressResult( + decision=EgressDecision.DENY, + decision_source=GateDecisionSource( + kind=DecisionSourceKind.GATE, + gate_name="agent-attestation-verifier", + gate_type="agent-attestation-verifier", + ), + reason_code=reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + + +__all__ = [ + "AttestedEgressProcessor", + "HarnessAdmissionProcessor", + "RECEIPT_HEADER", +] diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py new file mode 100644 index 00000000..8b10f593 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Short-lived Ed25519 agent attestations.""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +from datetime import UTC, datetime +from typing import Literal + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, +) +from pydantic import Field, ValidationError + +from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.models import ( + AdmissionHook, + AdmissionProvenance, + HarnessAdmissionContext, +) +from egress_gate.base import StrictDomainModel +from egress_gate.string_validators import BoundedMetadataString, ScalarString + + +class AgentAttestationClaimsV2(StrictDomainModel): + """Supervisor-only proof that one complete provider context was admitted.""" + + attestation_version: Literal["agent-attestation.v2"] = "agent-attestation.v2" + canonicalization_version: Literal["canonical-json.v1"] = "canonical-json.v1" + harness: ScalarString + harness_version: Literal["sdk-v1"] + harness_schema: ScalarString + hook: Literal["provider_context"] + middleware_binding: BoundedMetadataString + policy_fingerprint: ScalarString + sandbox_id: BoundedMetadataString + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + attestation_id: str = Field(pattern=r"^[0-9a-f]{32}$") + provider_adapter_schema: Literal["openai.request.v1"] + host: ScalarString + port: int = Field(ge=0, le=2**32 - 1) + subject_kind: Literal["context"] = "context" + subject_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + entry_count: int = Field(ge=1) + issued_at: int = Field(ge=0) + expires_at: int = Field(ge=0) + key_id: str = Field(pattern=r"^[0-9a-f]{16}$") + + +class ReceiptVerificationError(ValueError): + """A bounded receipt verification failure.""" + + def __init__(self, reason_code: str) -> None: + super().__init__(reason_code) + self.reason_code = reason_code + + +class ReceiptAuthority: + """Single-instance Ed25519 issuer and verifier with an ephemeral default key.""" + + def __init__( + self, + private_key: Ed25519PrivateKey | None = None, + *, + allowed_clock_skew_seconds: int = 5, + ) -> None: + if not 0 <= allowed_clock_skew_seconds <= 30: + raise ValueError("attestation clock skew must be between 0 and 30 seconds") + self._private_key = private_key or Ed25519PrivateKey.generate() + self._public_key = self._private_key.public_key() + public_bytes = self._public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + self._key_id = hashlib.sha256(public_bytes).hexdigest()[:16] + self._allowed_clock_skew_seconds = allowed_clock_skew_seconds + self._attestation_lifetime_seconds = 300 + + @property + def key_id(self) -> str: + """Return the non-secret identifier of the active ephemeral key.""" + return self._key_id + + def issue_attestation( + self, + subject_hash: str, + entry_count: int, + context: HarnessAdmissionContext, + provenance: AdmissionProvenance, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> bytes: + """Issue a retry-safe proof retained by the OpenShell supervisor.""" + if ( + context.harness_version != "sdk-v1" + or context.hook is not AdmissionHook.PROVIDER_CONTEXT + ): + raise ValueError("agent attestation context is unsupported") + issued_at = _now_seconds() if now is None else now + target = context.provider_target + claims = AgentAttestationClaimsV2( + harness=context.harness, + harness_version=context.harness_version, + harness_schema=context.schema_version, + hook=context.hook.value, + middleware_binding=context.middleware_name, + policy_fingerprint=policy_fingerprint, + sandbox_id=context.sandbox_id, + session_id=provenance.session_id, + submission_id=provenance.submission_id, + attestation_id=secrets.token_hex(16), + provider_adapter_schema=context.provider_adapter_schema, + host=target.host, + port=target.port, + subject_hash=subject_hash, + entry_count=entry_count, + issued_at=issued_at, + expires_at=issued_at + self._attestation_lifetime_seconds, + key_id=self._key_id, + ) + payload = canonical_json_bytes(claims) + signature = self._private_key.sign(payload) + return b"ag2." + _encode(payload) + b"." + _encode(signature) + + def verify_attestation( + self, + attestation: bytes, + subject_hash: str, + entry_count: int, + context: HarnessAdmissionContext, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> AgentAttestationClaimsV2: + """Verify a supervisor-supplied provider-context attestation.""" + payload, signature = _decode_token( + attestation, prefix=b"ag2", malformed_reason="attestation_malformed" + ) + try: + self._public_key.verify(signature, payload) + except InvalidSignature: + raise ReceiptVerificationError("attestation_signature_invalid") from None + try: + claims = AgentAttestationClaimsV2.model_validate_json(payload, strict=True) + except ValidationError: + raise ReceiptVerificationError("attestation_malformed") from None + if canonical_json_bytes(claims) != payload: + raise ReceiptVerificationError("attestation_malformed") + current = _now_seconds() if now is None else now + if claims.key_id != self._key_id: + raise ReceiptVerificationError("attestation_key_mismatch") + if claims.issued_at > current + self._allowed_clock_skew_seconds: + raise ReceiptVerificationError("attestation_not_yet_valid") + if claims.expires_at <= current or claims.expires_at <= claims.issued_at: + raise ReceiptVerificationError("attestation_expired") + target = context.provider_target + expected = ( + context.harness, + context.harness_version, + context.schema_version, + context.hook.value, + context.middleware_name, + policy_fingerprint, + context.sandbox_id, + context.provider_adapter_schema, + target.host, + target.port, + ) + actual = ( + claims.harness, + claims.harness_version, + claims.harness_schema, + claims.hook, + claims.middleware_binding, + claims.policy_fingerprint, + claims.sandbox_id, + claims.provider_adapter_schema, + claims.host, + claims.port, + ) + if actual != expected: + raise ReceiptVerificationError("attestation_context_mismatch") + if claims.entry_count != entry_count: + raise ReceiptVerificationError("entry_count_mismatch") + if claims.subject_hash != subject_hash: + raise ReceiptVerificationError("context_hash_mismatch") + return claims + + +def _encode(value: bytes) -> bytes: + return base64.urlsafe_b64encode(value).rstrip(b"=") + + +def _decode(value: bytes) -> bytes: + padding = b"=" * (-len(value) % 4) + try: + return base64.b64decode(value + padding, altchars=b"-_", validate=True) + except ValueError: + raise ValueError("token is malformed") from None + + +def _decode_token( + value: bytes, *, prefix: bytes, malformed_reason: str +) -> tuple[bytes, bytes]: + if len(value) > 8 * 1024: + raise ReceiptVerificationError(malformed_reason) + parts = value.split(b".") + if len(parts) != 3 or parts[0] != prefix or not parts[1] or not parts[2]: + raise ReceiptVerificationError(malformed_reason) + try: + return _decode(parts[1]), _decode(parts[2]) + except ValueError: + raise ReceiptVerificationError(malformed_reason) from None + + +def _now_seconds() -> int: + return int(datetime.now(UTC).timestamp()) + + +__all__ = [ + "AgentAttestationClaimsV2", + "ReceiptAuthority", + "ReceiptVerificationError", +] diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index c254b0f3..a611b35d 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -26,53 +26,91 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"y\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\"\xca\x01\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x16\n\x0emax_body_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"w\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\x82\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01*y\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xcd\x02\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResultb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xf1\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\x12\x19\n\x11\x61gent_attestation\x18\x08 \x01(\x0c\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x03*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._loaded_options = None + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_options = b'8\001' + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._loaded_options = None + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_options = b'8\001' + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._loaded_options = None + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=2037 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=2167 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=2169 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=2290 - _globals['_DECISION']._serialized_start=2292 - _globals['_DECISION']._serialized_end=2367 - _globals['_EXISTINGHEADERACTION']._serialized_start=2370 - _globals['_EXISTINGHEADERACTION']._serialized_end=2538 - _globals['_MIDDLEWAREMANIFEST']._serialized_start=115 - _globals['_MIDDLEWAREMANIFEST']._serialized_end=236 - _globals['_MIDDLEWAREBINDING']._serialized_start=239 - _globals['_MIDDLEWAREBINDING']._serialized_end=441 - _globals['_VALIDATECONFIGREQUEST']._serialized_start=443 - _globals['_VALIDATECONFIGREQUEST']._serialized_end=532 - _globals['_VALIDATECONFIGRESPONSE']._serialized_start=534 - _globals['_VALIDATECONFIGRESPONSE']._serialized_end=589 - _globals['_HTTPREQUESTEVALUATION']._serialized_start=592 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=934 - _globals['_HTTPHEADER']._serialized_start=936 - _globals['_HTTPHEADER']._serialized_end=977 - _globals['_REQUESTCONTEXT']._serialized_start=979 - _globals['_REQUESTCONTEXT']._serialized_end=1098 - _globals['_HTTPREQUESTTARGET']._serialized_start=1100 - _globals['_HTTPREQUESTTARGET']._serialized_end=1208 - _globals['_PROCESS']._serialized_start=1210 - _globals['_PROCESS']._serialized_end=1267 - _globals['_FINDING']._serialized_start=1269 - _globals['_FINDING']._serialized_end=1360 - _globals['_WRITEHEADER']._serialized_start=1362 - _globals['_WRITEHEADER']._serialized_end=1472 - _globals['_REMOVEHEADER']._serialized_start=1474 - _globals['_REMOVEHEADER']._serialized_end=1502 - _globals['_HEADERMUTATION']._serialized_start=1505 - _globals['_HEADERMUTATION']._serialized_end=1646 - _globals['_HTTPREQUESTRESULT']._serialized_start=1649 - _globals['_HTTPREQUESTRESULT']._serialized_end=2034 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=1987 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2034 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=2541 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=2874 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=4855 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=5096 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=5099 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=5311 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=5314 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=5892 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=5895 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=6083 + _globals['_DECISION']._serialized_start=6085 + _globals['_DECISION']._serialized_end=6160 + _globals['_EXISTINGHEADERACTION']._serialized_start=6163 + _globals['_EXISTINGHEADERACTION']._serialized_end=6331 + _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 + _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 + _globals['_MIDDLEWAREBINDING']._serialized_start=267 + _globals['_MIDDLEWAREBINDING']._serialized_end=527 + _globals['_VALIDATECONFIGREQUEST']._serialized_start=529 + _globals['_VALIDATECONFIGREQUEST']._serialized_end=618 + _globals['_VALIDATECONFIGRESPONSE']._serialized_start=620 + _globals['_VALIDATECONFIGRESPONSE']._serialized_end=675 + _globals['_HTTPREQUESTEVALUATION']._serialized_start=678 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=1047 + _globals['_HTTPHEADER']._serialized_start=1049 + _globals['_HTTPHEADER']._serialized_end=1090 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1093 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1395 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1398 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1721 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1723 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1776 + _globals['_WEBSOCKETMESSAGE']._serialized_start=1778 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1859 + _globals['_WEBSOCKETSESSIONEND']._serialized_start=1861 + _globals['_WEBSOCKETSESSIONEND']._serialized_end=1950 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1953 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2271 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2224 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2271 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2274 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2637 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2224 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2271 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2640 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2837 + _globals['_REQUESTCONTEXT']._serialized_start=2840 + _globals['_REQUESTCONTEXT']._serialized_end=3000 + _globals['_HTTPREQUESTTARGET']._serialized_start=3002 + _globals['_HTTPREQUESTTARGET']._serialized_end=3110 + _globals['_PROCESS']._serialized_start=3112 + _globals['_PROCESS']._serialized_end=3169 + _globals['_AGENTCONVERSATIONTARGET']._serialized_start=3172 + _globals['_AGENTCONVERSATIONTARGET']._serialized_end=3335 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=3338 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=3695 + _globals['_AGENTCONVERSATIONRESULT']._serialized_start=3698 + _globals['_AGENTCONVERSATIONRESULT']._serialized_end=4085 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2224 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2271 + _globals['_FINDING']._serialized_start=4087 + _globals['_FINDING']._serialized_end=4178 + _globals['_WRITEHEADER']._serialized_start=4180 + _globals['_WRITEHEADER']._serialized_end=4290 + _globals['_REMOVEHEADER']._serialized_start=4292 + _globals['_REMOVEHEADER']._serialized_end=4320 + _globals['_HEADERMUTATION']._serialized_start=4323 + _globals['_HEADERMUTATION']._serialized_end=4464 + _globals['_HTTPREQUESTRESULT']._serialized_start=4467 + _globals['_HTTPREQUESTRESULT']._serialized_end=4852 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2224 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2271 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=6334 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=6936 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index 10eac7f5..9549a7f1 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -13,11 +13,36 @@ class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWr __slots__ = () SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: _ClassVar[SupervisorMiddlewareOperation] + SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: _ClassVar[SupervisorMiddlewareOperation] class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: _ClassVar[SupervisorMiddlewarePhase] + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: _ClassVar[SupervisorMiddlewarePhase] + SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: _ClassVar[SupervisorMiddlewarePhase] + +class WebSocketSessionEndReason(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_CANCELLATION: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: _ClassVar[WebSocketSessionEndReason] + +class WebSocketPreflightAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_SKIP: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_DENY: _ClassVar[WebSocketPreflightAction] class Decision(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () @@ -33,8 +58,27 @@ class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase +SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: SupervisorMiddlewarePhase +SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: SupervisorMiddlewarePhase +WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_CANCELLATION: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: WebSocketSessionEndReason +WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_SKIP: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_DENY: WebSocketPreflightAction DECISION_UNSPECIFIED: Decision DECISION_ALLOW: Decision DECISION_DENY: Decision @@ -44,26 +88,34 @@ EXISTING_HEADER_ACTION_OVERWRITE: ExistingHeaderAction EXISTING_HEADER_ACTION_SKIP: ExistingHeaderAction class MiddlewareManifest(_message.Message): - __slots__ = ("name", "service_version", "bindings") + __slots__ = ("name", "service_version", "bindings", "expected_audience") NAME_FIELD_NUMBER: _ClassVar[int] SERVICE_VERSION_FIELD_NUMBER: _ClassVar[int] BINDINGS_FIELD_NUMBER: _ClassVar[int] + EXPECTED_AUDIENCE_FIELD_NUMBER: _ClassVar[int] name: str service_version: str bindings: _containers.RepeatedCompositeFieldContainer[MiddlewareBinding] - def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ...) -> None: ... + expected_audience: str + def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ..., expected_audience: _Optional[str] = ...) -> None: ... class MiddlewareBinding(_message.Message): - __slots__ = ("operation", "phase", "max_body_bytes", "timeout") + __slots__ = ("operation", "phase", "max_payload_bytes", "timeout", "harness", "hook", "schema_version") OPERATION_FIELD_NUMBER: _ClassVar[int] PHASE_FIELD_NUMBER: _ClassVar[int] - MAX_BODY_BYTES_FIELD_NUMBER: _ClassVar[int] + MAX_PAYLOAD_BYTES_FIELD_NUMBER: _ClassVar[int] TIMEOUT_FIELD_NUMBER: _ClassVar[int] + HARNESS_FIELD_NUMBER: _ClassVar[int] + HOOK_FIELD_NUMBER: _ClassVar[int] + SCHEMA_VERSION_FIELD_NUMBER: _ClassVar[int] operation: SupervisorMiddlewareOperation phase: SupervisorMiddlewarePhase - max_body_bytes: int + max_payload_bytes: int timeout: str - def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_body_bytes: _Optional[int] = ..., timeout: _Optional[str] = ...) -> None: ... + harness: str + hook: str + schema_version: str + def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_payload_bytes: _Optional[int] = ..., timeout: _Optional[str] = ..., harness: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ...) -> None: ... class ValidateConfigRequest(_message.Message): __slots__ = ("config", "middleware_name") @@ -82,7 +134,7 @@ class ValidateConfigResponse(_message.Message): def __init__(self, valid: _Optional[bool] = ..., reason: _Optional[str] = ...) -> None: ... class HttpRequestEvaluation(_message.Message): - __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name") + __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name", "agent_attestation") PHASE_FIELD_NUMBER: _ClassVar[int] CONTEXT_FIELD_NUMBER: _ClassVar[int] CONFIG_FIELD_NUMBER: _ClassVar[int] @@ -90,6 +142,7 @@ class HttpRequestEvaluation(_message.Message): HEADERS_FIELD_NUMBER: _ClassVar[int] BODY_FIELD_NUMBER: _ClassVar[int] MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + AGENT_ATTESTATION_FIELD_NUMBER: _ClassVar[int] phase: SupervisorMiddlewarePhase context: RequestContext config: _struct_pb2.Struct @@ -97,7 +150,8 @@ class HttpRequestEvaluation(_message.Message): headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] body: bytes middleware_name: str - def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ...) -> None: ... + agent_attestation: bytes + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ..., agent_attestation: _Optional[bytes] = ...) -> None: ... class HttpHeader(_message.Message): __slots__ = ("name", "value") @@ -107,15 +161,127 @@ class HttpHeader(_message.Message): value: str def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... +class WebSocketSessionEvent(_message.Message): + __slots__ = ("preflight", "session_start", "message", "session_end") + PREFLIGHT_FIELD_NUMBER: _ClassVar[int] + SESSION_START_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + SESSION_END_FIELD_NUMBER: _ClassVar[int] + preflight: WebSocketPreflight + session_start: WebSocketSessionStart + message: WebSocketMessage + session_end: WebSocketSessionEnd + def __init__(self, preflight: _Optional[_Union[WebSocketPreflight, _Mapping]] = ..., session_start: _Optional[_Union[WebSocketSessionStart, _Mapping]] = ..., message: _Optional[_Union[WebSocketMessage, _Mapping]] = ..., session_end: _Optional[_Union[WebSocketSessionEnd, _Mapping]] = ...) -> None: ... + +class WebSocketPreflight(_message.Message): + __slots__ = ("session_id", "phase", "context", "target", "requested_subprotocols", "middleware_name", "config") + SESSION_ID_FIELD_NUMBER: _ClassVar[int] + PHASE_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + REQUESTED_SUBPROTOCOLS_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + session_id: str + phase: SupervisorMiddlewarePhase + context: RequestContext + target: HttpRequestTarget + requested_subprotocols: _containers.RepeatedScalarFieldContainer[str] + middleware_name: str + config: _struct_pb2.Struct + def __init__(self, session_id: _Optional[str] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., requested_subprotocols: _Optional[_Iterable[str]] = ..., middleware_name: _Optional[str] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ... + +class WebSocketSessionStart(_message.Message): + __slots__ = ("selected_subprotocol",) + SELECTED_SUBPROTOCOL_FIELD_NUMBER: _ClassVar[int] + selected_subprotocol: str + def __init__(self, selected_subprotocol: _Optional[str] = ...) -> None: ... + +class WebSocketMessage(_message.Message): + __slots__ = ("sequence", "text", "binary") + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + BINARY_FIELD_NUMBER: _ClassVar[int] + sequence: int + text: str + binary: bytes + def __init__(self, sequence: _Optional[int] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ...) -> None: ... + +class WebSocketSessionEnd(_message.Message): + __slots__ = ("reason",) + REASON_FIELD_NUMBER: _ClassVar[int] + reason: WebSocketSessionEndReason + def __init__(self, reason: _Optional[_Union[WebSocketSessionEndReason, str]] = ...) -> None: ... + +class WebSocketPreflightDecision(_message.Message): + __slots__ = ("action", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ACTION_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + action: WebSocketPreflightAction + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, action: _Optional[_Union[WebSocketPreflightAction, str]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class WebSocketMessageResult(_message.Message): + __slots__ = ("sequence", "decision", "text", "binary", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + DECISION_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + BINARY_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + sequence: int + decision: Decision + text: str + binary: bytes + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, sequence: _Optional[int] = ..., decision: _Optional[_Union[Decision, str]] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class WebSocketSessionEventResult(_message.Message): + __slots__ = ("preflight_decision", "message_result") + PREFLIGHT_DECISION_FIELD_NUMBER: _ClassVar[int] + MESSAGE_RESULT_FIELD_NUMBER: _ClassVar[int] + preflight_decision: WebSocketPreflightDecision + message_result: WebSocketMessageResult + def __init__(self, preflight_decision: _Optional[_Union[WebSocketPreflightDecision, _Mapping]] = ..., message_result: _Optional[_Union[WebSocketMessageResult, _Mapping]] = ...) -> None: ... + class RequestContext(_message.Message): - __slots__ = ("request_id", "sandbox_id", "originating_process") + __slots__ = ("request_id", "sandbox_id", "originating_process", "sandbox_name", "workspace") REQUEST_ID_FIELD_NUMBER: _ClassVar[int] SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] ORIGINATING_PROCESS_FIELD_NUMBER: _ClassVar[int] + SANDBOX_NAME_FIELD_NUMBER: _ClassVar[int] + WORKSPACE_FIELD_NUMBER: _ClassVar[int] request_id: str sandbox_id: str originating_process: Process - def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ...) -> None: ... + sandbox_name: str + workspace: str + def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ..., sandbox_name: _Optional[str] = ..., workspace: _Optional[str] = ...) -> None: ... class HttpRequestTarget(_message.Message): __slots__ = ("scheme", "host", "port", "method", "path", "query") @@ -143,6 +309,73 @@ class Process(_message.Message): ancestors: _containers.RepeatedScalarFieldContainer[str] def __init__(self, binary: _Optional[str] = ..., pid: _Optional[int] = ..., ancestors: _Optional[_Iterable[str]] = ...) -> None: ... +class AgentConversationTarget(_message.Message): + __slots__ = ("harness", "harness_version", "hook", "schema_version", "scheme", "host", "port", "path") + HARNESS_FIELD_NUMBER: _ClassVar[int] + HARNESS_VERSION_FIELD_NUMBER: _ClassVar[int] + HOOK_FIELD_NUMBER: _ClassVar[int] + SCHEMA_VERSION_FIELD_NUMBER: _ClassVar[int] + SCHEME_FIELD_NUMBER: _ClassVar[int] + HOST_FIELD_NUMBER: _ClassVar[int] + PORT_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + harness: str + harness_version: str + hook: str + schema_version: str + scheme: str + host: str + port: int + path: str + def __init__(self, harness: _Optional[str] = ..., harness_version: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ..., scheme: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., path: _Optional[str] = ...) -> None: ... + +class AgentConversationEvaluation(_message.Message): + __slots__ = ("phase", "context", "config", "target", "middleware_name", "session_id", "turn_id", "request_body") + PHASE_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + SESSION_ID_FIELD_NUMBER: _ClassVar[int] + TURN_ID_FIELD_NUMBER: _ClassVar[int] + REQUEST_BODY_FIELD_NUMBER: _ClassVar[int] + phase: SupervisorMiddlewarePhase + context: RequestContext + config: _struct_pb2.Struct + target: AgentConversationTarget + middleware_name: str + session_id: str + turn_id: str + request_body: bytes + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[AgentConversationTarget, _Mapping]] = ..., middleware_name: _Optional[str] = ..., session_id: _Optional[str] = ..., turn_id: _Optional[str] = ..., request_body: _Optional[bytes] = ...) -> None: ... + +class AgentConversationResult(_message.Message): + __slots__ = ("decision", "reason", "attestation", "findings", "metadata", "reason_code", "replacement_body", "has_replacement_body") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + DECISION_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + ATTESTATION_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + REPLACEMENT_BODY_FIELD_NUMBER: _ClassVar[int] + HAS_REPLACEMENT_BODY_FIELD_NUMBER: _ClassVar[int] + decision: Decision + reason: str + attestation: bytes + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + reason_code: str + replacement_body: bytes + has_replacement_body: bool + def __init__(self, decision: _Optional[_Union[Decision, str]] = ..., reason: _Optional[str] = ..., attestation: _Optional[bytes] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ..., reason_code: _Optional[str] = ..., replacement_body: _Optional[bytes] = ..., has_replacement_body: _Optional[bool] = ...) -> None: ... + class Finding(_message.Message): __slots__ = ("type", "label", "count", "confidence", "severity") TYPE_FIELD_NUMBER: _ClassVar[int] diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py index a4914b37..e3421f1c 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py @@ -28,7 +28,8 @@ class SupervisorMiddlewareStub: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ def __init__(self, channel): @@ -52,11 +53,22 @@ def __init__(self, channel): request_serializer=supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, response_deserializer=supervisor__middleware__pb2.HttpRequestResult.FromString, _registered_method=True) + self.EvaluateAgentConversation = channel.unary_unary( + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateAgentConversation', + request_serializer=supervisor__middleware__pb2.AgentConversationEvaluation.SerializeToString, + response_deserializer=supervisor__middleware__pb2.AgentConversationResult.FromString, + _registered_method=True) + self.EvaluateWebSocketSession = channel.stream_stream( + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', + request_serializer=supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + response_deserializer=supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + _registered_method=True) class SupervisorMiddlewareServicer: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ def Describe(self, request, context): @@ -81,6 +93,27 @@ def EvaluateHttpRequest(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def EvaluateAgentConversation(self, request, context): + """EvaluateAgentConversation returns an allow, deny, or replacement decision for + one versioned, harness-native request before the harness commits or sends it. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def EvaluateWebSocketSession(self, request_iterator, context): + """EvaluateWebSocketSession opens one ordered, phase-specific stream for a + single middleware stage and WebSocket upgrade attempt. The current + implementation supports client-to-upstream text messages at + PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + A request may go unanswered when the session terminates. For every opened + stage stream, OpenShell attempts at most one session_end before closing the + stream when its transport is still writable. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_SupervisorMiddlewareServicer_to_server(servicer, server): rpc_method_handlers = { @@ -99,6 +132,16 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): request_deserializer=supervisor__middleware__pb2.HttpRequestEvaluation.FromString, response_serializer=supervisor__middleware__pb2.HttpRequestResult.SerializeToString, ), + 'EvaluateAgentConversation': grpc.unary_unary_rpc_method_handler( + servicer.EvaluateAgentConversation, + request_deserializer=supervisor__middleware__pb2.AgentConversationEvaluation.FromString, + response_serializer=supervisor__middleware__pb2.AgentConversationResult.SerializeToString, + ), + 'EvaluateWebSocketSession': grpc.stream_stream_rpc_method_handler( + servicer.EvaluateWebSocketSession, + request_deserializer=supervisor__middleware__pb2.WebSocketSessionEvent.FromString, + response_serializer=supervisor__middleware__pb2.WebSocketSessionEventResult.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) @@ -109,7 +152,8 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class SupervisorMiddleware: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ @staticmethod @@ -192,3 +236,57 @@ def EvaluateHttpRequest(request, timeout, metadata, _registered_method=True) + + @staticmethod + def EvaluateAgentConversation(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateAgentConversation', + supervisor__middleware__pb2.AgentConversationEvaluation.SerializeToString, + supervisor__middleware__pb2.AgentConversationResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def EvaluateWebSocketSession(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream( + request_iterator, + target, + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', + supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 6f789c29..5e2c4491 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -64,7 +64,12 @@ validate_gateway_timeout, validate_middleware_name, ) -from egress_gate.logging import LoggingConfig, configure_logging, get_logger +from egress_gate.logging import ( + LoggingConfig, + configure_json_log, + configure_logging, + get_logger, +) from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext from egress_gate.result import EgressResult, GateDecisionSource from egress_gate.string_validators import BoundedMetadataString @@ -167,6 +172,24 @@ def serve( ), ), ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", + json_log: Annotated[ + Path | None, + typer.Option( + "--json-log", + help="Write content-safe evaluation records as newline-delimited JSON.", + ), + ] = None, + require_agent_attestation: Annotated[ + bool, + typer.Option( + "--require-agent-attestation/--no-require-agent-attestation", + help=( + "Require a supervisor-held agent context attestation on HTTP " + "egress. Enabled by default; disable only for an " + "explicitly unmanaged deployment." + ), + ), + ] = True, ) -> None: """Start the Egress Gate gRPC service and run until shutdown.""" options = _command_options(context) @@ -205,10 +228,13 @@ def serve( remembered.middleware_name, remembered.config_path, ) + if json_log is not None: + configure_json_log(json_log) try: EgressGateServer( options.registry, timeout_middleware_processing=timeout_middleware_processing, + require_agent_attestation=require_agent_attestation, ).serve_sync(listen) except EgressGateError as error: _render_egress_error("Egress Gate could not start", error) diff --git a/projects/egress-gate/src/egress_gate/constants.py b/projects/egress-gate/src/egress_gate/constants.py index a854ef9b..3dde4144 100644 --- a/projects/egress-gate/src/egress_gate/constants.py +++ b/projects/egress-gate/src/egress_gate/constants.py @@ -79,6 +79,7 @@ MAX_PROTO_TARGET_BYTES = 32 * 1024 MAX_PROTO_HEADERS = 128 MAX_PROTO_HEADERS_BYTES = 64 * 1024 +MAX_AGENT_ATTESTATION_BYTES = 8 * 1024 PROTOBUF_ENVELOPE_ALLOWANCE_BYTES = 1024 * 1024 MAX_RECEIVE_MESSAGE_BYTES = MAX_BODY_BYTES + PROTOBUF_ENVELOPE_ALLOWANCE_BYTES diff --git a/projects/egress-gate/src/egress_gate/logging.py b/projects/egress-gate/src/egress_gate/logging.py index ed135326..38bb65ec 100644 --- a/projects/egress-gate/src/egress_gate/logging.py +++ b/projects/egress-gate/src/egress_gate/logging.py @@ -6,10 +6,12 @@ from __future__ import annotations import copy +import json import logging import os from dataclasses import dataclass from enum import StrEnum +from pathlib import Path from typing import TextIO @@ -65,13 +67,26 @@ def configure_logging( package_logger.propagate = False +def configure_json_log(path: Path) -> None: + """Write content-safe Egress Gate records as newline-delimited JSON.""" + package_logger = get_logger("egress_gate") + for handler in package_logger.handlers[:]: + if isinstance(handler, _EgressGateJsonHandler): + package_logger.removeHandler(handler) + handler.close() + + handler = _EgressGateJsonHandler(path, encoding="utf-8") + handler.setFormatter(_EgressGateJsonFormatter()) + package_logger.addHandler(handler) + + def reset_logging() -> None: """Remove logging configuration installed by :func:`configure_logging`.""" package_logger = get_logger("egress_gate") managed_handlers = [ handler for handler in package_logger.handlers - if isinstance(handler, _EgressGateStreamHandler) + if isinstance(handler, _EgressGateStreamHandler | _EgressGateJsonHandler) ] if not managed_handlers: return @@ -87,6 +102,29 @@ class _EgressGateStreamHandler(logging.StreamHandler[TextIO]): """Stream handler owned by Egress Gate's logging configuration.""" +class _EgressGateJsonHandler(logging.FileHandler): + """Optional content-safe JSON sink owned by Egress Gate.""" + + +class _EgressGateJsonFormatter(logging.Formatter): + """Serialize only the bounded evaluation fields used by verification.""" + + def format(self, record: logging.LogRecord) -> str: + return json.dumps( + { + "event": getattr(record, "event", record.getMessage()), + "request_id": getattr(record, "request_id", None), + "duration_ms": getattr(record, "duration_ms", None), + "action": getattr(record, "action", None), + "reason_code": getattr(record, "reason_code", None), + "finding_count": getattr(record, "finding_count", None), + "decision_source_kind": getattr(record, "decision_source_kind", None), + "error_code": getattr(record, "error_code", None), + }, + separators=(",", ":"), + ) + + class _EgressGateFormatter(logging.Formatter): """Readable console formatter with optional level-aware color.""" @@ -129,6 +167,7 @@ def format(self, record: logging.LogRecord) -> str: "ColorMode", "DEFAULT_LOGGING_CONFIG", "LoggingConfig", + "configure_json_log", "configure_logging", "get_logger", "reset_logging", diff --git a/projects/egress-gate/src/egress_gate/request.py b/projects/egress-gate/src/egress_gate/request.py index 98201ffe..7ac9fa1c 100644 --- a/projects/egress-gate/src/egress_gate/request.py +++ b/projects/egress-gate/src/egress_gate/request.py @@ -26,6 +26,22 @@ HeaderValue = ScalarString +class EnforcementPoint(StrEnum): + """The trusted boundary at which a request is being evaluated.""" + + NETWORK_EGRESS = "network_egress" + HARNESS_ADMISSION = "harness_admission" + + +class HarnessAdmissionMetadata(StrictDomainModel): + """Bounded harness-shape metadata stamped by the trusted transport.""" + + harness: ScalarString + harness_version: ScalarString + hook: ScalarString + schema_version: ScalarString + + class Process(StrictDomainModel): """The originating workload process and its executable ancestry.""" @@ -40,6 +56,8 @@ class RequestContext(StrictDomainModel): request_id: ScalarString sandbox_id: ScalarString originating_process: Process | None = None + enforcement_point: EnforcementPoint = EnforcementPoint.NETWORK_EGRESS + harness_admission: HarnessAdmissionMetadata | None = None @model_validator(mode="after") def _context_strings_are_bounded(self) -> RequestContext: @@ -52,8 +70,23 @@ def _context_strings_are_bounded(self) -> RequestContext: len(ancestor.encode("utf-8")) for ancestor in self.originating_process.ancestors ) + if self.harness_admission is not None: + string_bytes += sum( + len(value.encode("utf-8")) + for value in ( + self.harness_admission.harness, + self.harness_admission.harness_version, + self.harness_admission.hook, + self.harness_admission.schema_version, + ) + ) if string_bytes > MAX_PROTO_CONTEXT_BYTES: raise ValueError("request context strings exceed the size limit") + if self.enforcement_point is EnforcementPoint.HARNESS_ADMISSION: + if self.harness_admission is None: + raise ValueError("harness admission requires trusted metadata") + elif self.harness_admission is not None: + raise ValueError("network egress cannot carry harness metadata") return self @@ -178,10 +211,12 @@ def is_empty(self) -> bool: __all__ = [ + "EnforcementPoint", "ExistingHeaderAction", "HeaderMutation", "HeaderName", "HeaderValue", + "HarnessAdmissionMetadata", "HttpHeader", "HttpRequest", "HttpTarget", diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index 0b94dadb..e19d3fe6 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -92,6 +92,11 @@ def __init__( self._gates = gates self._policy_fingerprint = policy_fingerprint + @property + def policy_fingerprint(self) -> str | None: + """Return the immutable fingerprint of the prepared policy.""" + return self._policy_fingerprint + def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: """Evaluate one request and return an atomic final domain result.""" if not isinstance(request, HttpRequest): diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index 146838d6..a1c2e6fb 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -34,10 +34,12 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, + require_agent_attestation: bool = False, ) -> None: self._middleware = EgressGateMiddleware( registry, timeout_middleware_processing=timeout_middleware_processing, + require_agent_attestation=require_agent_attestation, ) def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index a5099a08..5bf22573 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -12,12 +12,27 @@ from collections.abc import Callable, Iterable from concurrent.futures import Future, ThreadPoolExecutor from threading import Lock -from typing import Never, Protocol, TypedDict, TypeVar +from typing import Literal, Never, Protocol, TypedDict, TypeVar import grpc from google.protobuf import json_format from google.protobuf.message import Message +from egress_gate.admission import ( + MAX_ADMISSION_BODY_BYTES, + PI_HARNESS_VERSION, + RECEIPT_HEADER, + AdmissionDecision, + AdmissionHook, + AdmissionProvenance, + AttestedEgressProcessor, + HarnessAdmissionContext, + HarnessAdmissionProcessor, + HarnessAdmissionRequest, + ReceiptAuthority, + create_pi_adapter_registry, + create_provider_adapter_registry, +) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc from egress_gate.config import EgressGateConfig @@ -26,6 +41,7 @@ DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, LIMIT_REASON, LIMIT_REASON_CODE, + MAX_AGENT_ATTESTATION_BYTES, MAX_BODY_BYTES, MAX_CONCURRENT_PROCESSING, MAX_PROTO_CONFIG_BYTES, @@ -65,6 +81,7 @@ DecisionSourceKind, EgressDecision, EgressResult, + GateDecisionSource, SourcedFinding, ) from egress_gate.string_validators import validate_bounded_metadata_string @@ -75,6 +92,12 @@ ) +def _require_harness_version(value: str) -> Literal["sdk-v1"]: + if value == PI_HARNESS_VERSION: + return value + raise ValueError("invalid admission harness version") + + class EgressGateMiddleware(pb2_grpc.SupervisorMiddlewareServicer): """Validate, prepare, resolve, and run Egress Gate policies.""" @@ -83,6 +106,7 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, + require_agent_attestation: bool = False, ) -> None: registry.configuration_json_schema() self._registry = registry @@ -90,6 +114,9 @@ def __init__( validate_timeout_middleware_processing(timeout_middleware_processing) ) self._policy = _ActivePolicy(registry) + self._receipt_authority = ReceiptAuthority() + self._admission_adapters = create_pi_adapter_registry() + self._require_agent_attestation = require_agent_attestation self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) self._processing_executor = ThreadPoolExecutor( max_workers=MAX_CONCURRENT_PROCESSING, @@ -124,8 +151,22 @@ async def Describe( pb2.MiddlewareBinding( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, - max_body_bytes=MAX_BODY_BYTES, - ) + max_payload_bytes=MAX_BODY_BYTES, + ), + *( + pb2.MiddlewareBinding( + operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION, + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, + max_payload_bytes=MAX_ADMISSION_BODY_BYTES, + harness=harness, + hook=hook, + schema_version=schema_version, + ) + for harness, hook, schema_version in ( + self._admission_adapters.bindings + ) + if self._require_agent_attestation + ), ], ) @@ -151,6 +192,94 @@ async def EvaluateHttpRequest( """Resolve the prepared pipeline and evaluate one current request.""" return await self._evaluate_rpc(request, context) + async def EvaluateAgentConversation( + self, + request: pb2.AgentConversationEvaluation, + context: grpc.aio.ServicerContext[ + pb2.AgentConversationEvaluation, + pb2.AgentConversationResult, + ], + ) -> pb2.AgentConversationResult: + """Evaluate one supervisor-stamped agent admission request.""" + timeout = Timeout.from_seconds(self._timeout_middleware_processing_seconds) + return await self._run_in_worker( + lambda: self._evaluate_agent_admission(request, timeout), + timeout=timeout, + ) + + def _evaluate_agent_admission( + self, + request: pb2.AgentConversationEvaluation, + timeout: Timeout, + ) -> pb2.AgentConversationResult: + try: + if not self._require_agent_attestation: + raise ValueError("agent admission is disabled") + if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: + raise ValueError("invalid admission phase") + if len(request.request_body) > MAX_ADMISSION_BODY_BYTES: + raise ValueError("admission request body is too large") + hook = AdmissionHook(request.target.hook) + target = HttpTarget( + scheme=request.target.scheme, + host=request.target.host, + port=request.target.port, + method="POST", + path=request.target.path, + query="", + ) + provenance = AdmissionProvenance( + session_id=request.session_id, + submission_id=request.turn_id, + ) + processor = HarnessAdmissionProcessor( + self._policy.processor_for( + _mapping_from_proto(request.config), timeout=timeout + ), + self._admission_adapters, + self._receipt_authority, + ) + result = processor.process( + HarnessAdmissionRequest( + request_body=request.request_body, + provenance=provenance, + ), + HarnessAdmissionContext( + request_id=request.context.request_id, + sandbox_id=request.context.sandbox_id, + middleware_name=request.middleware_name, + harness=request.target.harness, + harness_version=_require_harness_version( + request.target.harness_version + ), + hook=hook, + schema_version=request.target.schema_version, + provider_target=target, + provider_adapter_schema="openai.request.v1", + ), + timeout=timeout, + ) + response = pb2.AgentConversationResult( + decision=( + pb2.DECISION_DENY + if result.decision is AdmissionDecision.DENY + else pb2.DECISION_ALLOW + ), + reason_code=result.reason_code or "", + attestation=result.attestation or b"", + replacement_body=result.replacement_body or b"", + has_replacement_body=result.replacement_body is not None, + ) + response.findings.extend( + _finding_to_proto(item) for item in result.findings + ) + return response + except Exception: + return pb2.AgentConversationResult( + decision=pb2.DECISION_DENY, + reason_code="admission_unavailable", + ) + def _validate_config( self, request: pb2.ValidateConfigRequest, @@ -175,6 +304,7 @@ async def _evaluate_rpc( request_id = _request_id_for_logging(request.context.request_id) failure: EgressGateError | None = None action = "error" + reason_code: str | None = None finding_count = 0 source_kind = "none" try: @@ -184,11 +314,13 @@ async def _evaluate_rpc( timeout, ) action = "allow" if response.decision == pb2.DECISION_ALLOW else "deny" + reason_code = response.reason_code or None finding_count = sum(finding.count for finding in response.findings) return response except TimeoutExpiredError: response = _limit_deny() action = "deny" + reason_code = response.reason_code or None source_kind = DecisionSourceKind.RUNTIME_LIMIT.value return response except EgressGateError as error: @@ -200,6 +332,7 @@ async def _evaluate_rpc( request_id=request_id, started=started, action=action, + reason_code=reason_code, finding_count=finding_count, source_kind=source_kind, failure=failure, @@ -255,6 +388,31 @@ def _prepare_and_process( values, timeout=timeout, ) + if self._require_agent_attestation: + return AttestedEgressProcessor( + processor, + create_provider_adapter_registry(), + self._receipt_authority, + middleware_name=request.middleware_name, + harness_version=PI_HARNESS_VERSION, + ).process( + domain_request, + agent_attestation=request.agent_attestation, + timeout=timeout, + ) + if any( + header.name.lower() == RECEIPT_HEADER for header in domain_request.headers + ): + return EgressResult( + decision=EgressDecision.DENY, + decision_source=GateDecisionSource( + kind=DecisionSourceKind.GATE, + gate_name="reserved-receipt-header", + gate_type="reserved-receipt-header", + ), + reason_code="reserved_header_present", + policy_fingerprint=processor.policy_fingerprint, + ) return processor.process(domain_request, timeout=timeout) async def _run_in_worker( @@ -348,9 +506,11 @@ async def abort(self, code: grpc.StatusCode, details: str) -> Never: ... class _EvaluationLogExtra(TypedDict): + event: str request_id: str duration_ms: float action: str + reason_code: str | None finding_count: int decision_source_kind: str error_code: str | None @@ -361,14 +521,17 @@ def _evaluation_log_extra( request_id: str, started: float, action: str, + reason_code: str | None, finding_count: int, source_kind: str, failure: EgressGateError | None, ) -> _EvaluationLogExtra: return { + "event": "egress_gate_evaluation", "request_id": request_id, "duration_ms": round((time.monotonic() - started) * 1000, 3), "action": action, + "reason_code": reason_code, "finding_count": finding_count, "decision_source_kind": source_kind, "error_code": failure.code.value if failure is not None else None, @@ -457,6 +620,7 @@ def _validate_evaluation_envelope(request: pb2.HttpRequestEvaluation) -> None: or request.target.ByteSize() > MAX_PROTO_TARGET_BYTES or len(request.headers) > MAX_PROTO_HEADERS or _encoded_headers_size(request.headers) > MAX_PROTO_HEADERS_BYTES + or len(request.agent_attestation) > MAX_AGENT_ATTESTATION_BYTES ): raise EgressGateError(ErrorCode.REQUEST_ENVELOPE_INVALID) diff --git a/projects/egress-gate/tests/admission/__init__.py b/projects/egress-gate/tests/admission/__init__.py new file mode 100644 index 00000000..a60fe663 --- /dev/null +++ b/projects/egress-gate/tests/admission/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Admission and attested-egress tests.""" diff --git a/projects/egress-gate/tests/admission/fixtures/README.md b/projects/egress-gate/tests/admission/fixtures/README.md new file mode 100644 index 00000000..f490dba4 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/README.md @@ -0,0 +1,20 @@ +# Pi provider fixture provenance + +These payloads were captured on 2026-09-02 from Pi commit +`61500e60394060f2f56a76c61a0067c33988c9f8` through the native stream +adapters' `onPayload` fake-fetch boundary. The capture used the three models +and compatibility settings checked into the attested-admission example. No +provider request was sent. + +The strict adapter decisions are deliberate: + +- Chat Completions accepts assistant `tool_calls`, tool replies, and the + `reasoning_content` string emitted when Pi replays Qwen reasoning. That + reasoning field is preserved for validation but is not projected as message + text. +- Responses accepts replayed `reasoning`, assistant `message`, + `function_call`, and `function_call_output` items, including the optional + reasoning fields present in the captured payload. +- Unknown fields, explicit nulls for optional compatibility fields, image + inputs, and mixed top-level Chat Completions/Responses shapes remain + unsupported and fail closed. diff --git a/projects/egress-gate/tests/admission/fixtures/context-entries.json b/projects/egress-gate/tests/admission/fixtures/context-entries.json new file mode 100644 index 00000000..75c4e279 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/context-entries.json @@ -0,0 +1,55 @@ +{ + "cases": [ + { + "name": "converted-history-origins", + "context": { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "The conversation history before this point was compacted into the following summary:\n\n\ncompact text\n" + } + ] + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "assistant text"}] + }, + { + "role": "user", + "content": "Ran `printf safe`\n```\nbash text\n```" + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "extension text"}, + {"type": "text", "text": "continued"} + ] + }, + { + "role": "toolResult", + "toolCallId": "call-1|provider-id", + "toolName": "read", + "content": [{"type": "text", "text": "tool text"}], + "isError": false + } + ], + "tools": [] + }, + "entries": [ + { + "role": "user", + "text": "The conversation history before this point was compacted into the following summary:\n\n\ncompact text\n" + }, + { + "role": "user", + "text": "Ran `printf safe`\n```\nbash text\n```" + }, + {"role": "user", "text": "extension text\ncontinued"}, + {"role": "tool", "tool_call_id": "call-1", "text": "tool text"} + ] + } + ] +} diff --git a/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json b/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json new file mode 100644 index 00000000..d912da65 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json @@ -0,0 +1,130 @@ +{ + "user_request": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "safe"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ] + }, + "opus": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": "I will read it.", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ] + }, + "qwen": { + "model": "nvidia/qwen/qwen3.8-flash-next", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": null, + "reasoning_content": "prior reasoning", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 32768, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ], + "enable_thinking": true, + "reasoning_effort": "high" + }, + "compaction_summary": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": "I will read it.", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"}, + {"role": "user", "content": "Summary:\ncompacted context"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ] + } +} diff --git a/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json b/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json new file mode 100644 index 00000000..315061b9 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json @@ -0,0 +1,73 @@ +{ + "user_request": { + "model": "azure/openai/gpt-5.6-sol", + "input": [ + {"role": "developer", "content": "fixture system prompt"}, + {"role": "user", "content": [{"type": "input_text", "text": "safe"}]} + ], + "stream": true, + "store": false, + "max_output_tokens": 128000, + "tools": [ + { + "type": "function", + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}} + } + ], + "reasoning": {"effort": "high", "summary": "auto"}, + "include": ["reasoning.encrypted_content"] + }, + "tool_result_request": { + "model": "azure/openai/gpt-5.6-sol", + "input": [ + {"role": "developer", "content": "fixture system prompt"}, + { + "role": "user", + "content": [{"type": "input_text", "text": "use the tool"}] + }, + { + "type": "reasoning", + "id": "rs-1", + "summary": [{"type": "summary_text", "text": "summary"}], + "content": [{"type": "reasoning_text", "text": "reasoning"}], + "encrypted_content": "encrypted", + "status": "completed" + }, + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "I will read it.", "annotations": []} + ], + "status": "completed", + "id": "msg_pi_1" + }, + { + "type": "function_call", + "call_id": "call-1", + "name": "read", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "call-1", + "output": "safe tool output" + } + ], + "stream": true, + "store": false, + "max_output_tokens": 128000, + "tools": [ + { + "type": "function", + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}} + } + ], + "reasoning": {"effort": "high", "summary": "auto"}, + "include": ["reasoning.encrypted_content"] + } +} diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py new file mode 100644 index 00000000..f53df31b --- /dev/null +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -0,0 +1,885 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance tests for managed Pi context admission and attested egress.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Literal + +import pytest + +from egress_gate.admission import ( + MAX_ADMISSION_BODY_BYTES, + RECEIPT_HEADER, + AdmissionDecision, + AdmissionHook, + AdmissionProvenance, + AttestedEgressProcessor, + HarnessAdmissionContext, + HarnessAdmissionProcessor, + HarnessAdmissionRequest, + PiAssistantMessageV1, + PiAssistantToolCallV1, + PiBashExecutionV1, + PiMessageV1, + PiProviderContextV1, + PiTextContentV1, + PiToolResultV1, + ReceiptAuthority, + canonical_json_bytes, + create_pi_adapter_registry, + create_provider_adapter_registry, +) +from egress_gate.gates import create_builtin_registry +from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext +from egress_gate.timeout import Timeout + +DENY_TEXT = "DENY_THIS" +REDACT_TEXT = "REDACT_THIS" +_PI_RESPONSES_FIXTURES = json.loads( + (Path(__file__).parent / "fixtures/pi-openai-responses.json").read_text() +) +_PI_CHAT_FIXTURES = json.loads( + (Path(__file__).parent / "fixtures/pi-openai-completions.json").read_text() +) +_CONTEXT_ENTRY_VECTORS = json.loads( + (Path(__file__).parent / "fixtures/context-entries.json").read_text() +) +# These payloads were captured at Pi's fake-fetch boundary from its native +# openai-responses and openai-completions stream functions. They intentionally +# preserve the serializer output rather than restating it through test builders. + + +def _processors( + *, replacement_template: str = "[REDACTED]" +) -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor, ReceiptAuthority]: + registry = create_builtin_registry() + config = registry.validate_config( + { + "gates": [ + { + "name": "deny-marker", + "kind": "regex", + "scan": {"kind": "body", "action": {"kind": "deny"}}, + "pattern_catalog": { + "entities": [ + { + "name": "unsafe-marker", + "rules": [ + { + "name": "exact-marker", + "pattern": DENY_TEXT, + "confidence": "high", + } + ], + } + ] + }, + }, + { + "name": "replace-marker", + "kind": "regex", + "scan": { + "kind": "body", + "action": { + "kind": "replace", + "template": replacement_template, + }, + }, + "pattern_catalog": { + "entities": [ + { + "name": "replacement-marker", + "rules": [ + { + "name": "exact-marker", + "pattern": REDACT_TEXT, + "confidence": "high", + } + ], + } + ] + }, + }, + ], + "default_decision": "allow", + } + ) + request_processor = registry.prepare_processor( + config, timeout=Timeout.from_seconds(1) + ) + authority = ReceiptAuthority() + return ( + HarnessAdmissionProcessor( + request_processor, create_pi_adapter_registry(), authority + ), + AttestedEgressProcessor( + request_processor, + create_provider_adapter_registry(), + authority, + middleware_name="pi-egress", + harness_version="sdk-v1", + ), + authority, + ) + + +def _target(*, host: str = "provider.test") -> HttpTarget: + return HttpTarget( + scheme="https", + host=host, + port=443, + method="POST", + path="/v1/chat/completions", + query="", + ) + + +def _context( + hook: AdmissionHook, + *, + target: HttpTarget | None = None, +) -> HarnessAdmissionContext: + schema = { + AdmissionHook.USER_MESSAGE: "openshell.pi-message.v1", + AdmissionHook.COMPACTION_SUMMARY: "openshell.pi-message.v1", + AdmissionHook.BRANCH_SUMMARY: "openshell.pi-message.v1", + AdmissionHook.EXTENSION_MESSAGE: "openshell.pi-message.v1", + AdmissionHook.TOOL_RESULT: "openshell.pi-tool-result.v1", + AdmissionHook.ASSISTANT_MESSAGE: "openshell.pi-assistant-message.v1", + AdmissionHook.BASH_EXECUTION: "openshell.pi-bash-execution.v1", + AdmissionHook.PROVIDER_CONTEXT: "openshell.pi-provider-context.v1", + }[hook] + return HarnessAdmissionContext( + request_id="admission-1", + sandbox_id="sandbox-1", + middleware_name="pi-egress", + harness="pi", + harness_version="sdk-v1", + hook=hook, + schema_version=schema, + provider_target=target or _target(), + provider_adapter_schema="openai.request.v1", + ) + + +def _admit( + processor: HarnessAdmissionProcessor, + value: ( + PiMessageV1 + | PiToolResultV1 + | PiAssistantMessageV1 + | PiBashExecutionV1 + | PiProviderContextV1 + ), + *, + target: HttpTarget | None = None, + timeout: Timeout | None = None, +): + if isinstance(value, PiMessageV1): + hook = { + "user": AdmissionHook.USER_MESSAGE, + "compaction_summary": AdmissionHook.COMPACTION_SUMMARY, + "branch_summary": AdmissionHook.BRANCH_SUMMARY, + "extension_message": AdmissionHook.EXTENSION_MESSAGE, + }[value.origin] + elif isinstance(value, PiToolResultV1): + hook = AdmissionHook.TOOL_RESULT + elif isinstance(value, PiAssistantMessageV1): + hook = AdmissionHook.ASSISTANT_MESSAGE + elif isinstance(value, PiProviderContextV1): + hook = AdmissionHook.PROVIDER_CONTEXT + else: + hook = AdmissionHook.BASH_EXECUTION + return processor.process( + HarnessAdmissionRequest( + request_body=canonical_json_bytes(value), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(hook, target=target), + timeout=timeout or Timeout.from_seconds(1), + ) + + +def _message( + text: str, + *, + origin: Literal[ + "user", "compaction_summary", "branch_summary", "extension_message" + ] = "user", +) -> PiMessageV1: + return PiMessageV1( + schema_version="openshell.pi-message.v1", origin=origin, text=text + ) + + +def _user(text: str) -> PiMessageV1: + return _message(text) + + +def _assistant( + text: str, *, arguments: dict[str, object] | None = None +) -> PiAssistantMessageV1: + return PiAssistantMessageV1( + schema_version="openshell.pi-assistant-message.v1", + text=text, + tool_calls=( + PiAssistantToolCallV1( + id="call-1", name="read", arguments=arguments or {"path": "safe"} + ), + ), + ) + + +def _bash(output: str, *, command: str = "printf safe") -> PiBashExecutionV1: + return PiBashExecutionV1( + schema_version="openshell.pi-bash-execution.v1", + command=command, + output=output, + exit_code=0, + ) + + +def _tool_result( + text: str, *, image: bool = False, tool_call_id: str = "call-1" +) -> PiToolResultV1: + content: list[dict[str, object]] = ( + [{"type": "image", "data": "AA==", "mimeType": "image/png"}] + if image + else [{"type": "text", "text": text}] + ) + return PiToolResultV1.model_validate( + { + "schema_version": "openshell.pi-tool-result.v1", + "tool_call_id": tool_call_id, + "tool_name": "read", + "content": content, + "is_error": False, + }, + strict=True, + ) + + +def _provider_request( + prompt: str, + *, + tool_result: str | None = None, + headers: tuple[HttpHeader, ...] = (), + target: HttpTarget | None = None, +) -> HttpRequest: + provider_body = json.loads(json.dumps(_PI_CHAT_FIXTURES["user_request"])) + provider_body["messages"][1]["content"] = prompt + if tool_result is not None: + provider_body["messages"].extend( + [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "content": tool_result, + "tool_call_id": "call-1", + }, + ] + ) + body = json.dumps( + provider_body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + return HttpRequest( + context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), + target=target or _target(), + headers=(HttpHeader(name="content-type", value="application/json"),) + headers, + body=body, + ) + + +def _responses_request( + prompt: str, + *, + tool_result: str | None = None, +) -> HttpRequest: + fixture_name = "tool_result_request" if tool_result is not None else "user_request" + provider_body = json.loads(json.dumps(_PI_RESPONSES_FIXTURES[fixture_name])) + provider_body["input"][1]["content"][0]["text"] = prompt + if tool_result is not None: + provider_body["input"][-1]["output"] = tool_result + body = json.dumps( + provider_body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + return HttpRequest( + context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), + target=_target().model_copy(update={"path": "/v1/responses"}), + headers=(HttpHeader(name="content-type", value="application/json"),), + body=body, + ) + + +def _egress( + processor: AttestedEgressProcessor, + request: HttpRequest, + attestation: bytes | None, +): + return processor.process( + request, + agent_attestation=attestation or b"", + timeout=Timeout.from_seconds(1), + ) + + +def _admit_provider_request( + admission: HarnessAdmissionProcessor, + request: HttpRequest, +): + registry = create_provider_adapter_registry() + adapter = registry.resolve_request(request, Timeout.from_seconds(1)) + return _admit( + admission, + PiProviderContextV1( + schema_version="openshell.pi-provider-context.v1", + entries=adapter.attested_entries(request, Timeout.from_seconds(1)), + ), + target=request.target, + ) + + +@pytest.mark.parametrize("fixture_name", ["opus", "qwen", "compaction_summary"]) +def test_complete_pi_chat_context_is_attested(fixture_name: str) -> None: + admission, egress, _ = _processors() + request = _provider_request("safe").model_copy( + update={ + "body": json.dumps( + _PI_CHAT_FIXTURES[fixture_name], + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + admitted = _admit_provider_request(admission, request) + + result = _egress(egress, request, admitted.attestation) + + assert admitted.attestation is not None + assert admitted.attestation.startswith(b"ag2.") + assert result.decision.value == "allow" + + +def test_provider_adapters_match_shared_context_entry_vectors() -> None: + expected = PiProviderContextV1.model_validate( + { + "schema_version": "openshell.pi-provider-context.v1", + "entries": _CONTEXT_ENTRY_VECTORS["cases"][0]["entries"], + }, + strict=True, + ).entries + source_messages = [ + message + for message in _CONTEXT_ENTRY_VECTORS["cases"][0]["context"]["messages"] + if message["role"] in {"user", "toolResult"} + ] + messages = [{"role": "system", "content": "system"}] + responses_input = [{"role": "developer", "content": "system"}] + for entry, source in zip(expected, source_messages, strict=True): + if entry.role == "user": + messages.append({"role": "user", "content": source["content"]}) + content = source["content"] + responses_input.append( + { + "role": "user", + "content": ( + content + if isinstance(content, str) + else [ + {"type": "input_text", "text": block["text"]} + for block in content + ] + ), + } + ) + else: + messages.append( + { + "role": "tool", + "content": entry.text, + "tool_call_id": entry.tool_call_id, + } + ) + responses_input.append( + { + "type": "function_call_output", + "call_id": entry.tool_call_id, + "output": entry.text, + } + ) + chat_body = json.loads(json.dumps(_PI_CHAT_FIXTURES["user_request"])) + chat_body["messages"] = messages + chat = _provider_request("unused").model_copy( + update={"body": json.dumps(chat_body, separators=(",", ":")).encode()} + ) + responses_body = json.loads(json.dumps(_PI_RESPONSES_FIXTURES["user_request"])) + responses_body["input"] = responses_input + responses = _responses_request("unused").model_copy( + update={"body": json.dumps(responses_body, separators=(",", ":")).encode()} + ) + registry = create_provider_adapter_registry() + + assert ( + registry.resolve_request(chat, Timeout.from_seconds(1)).attested_entries( + chat, Timeout.from_seconds(1) + ) + == expected + ) + assert ( + registry.resolve_request(responses, Timeout.from_seconds(1)).attested_entries( + responses, Timeout.from_seconds(1) + ) + == expected + ) + + +def test_complete_responses_context_authorizes_retries() -> None: + admission, egress, _ = _processors() + request = _responses_request("use the tool", tool_result="safe tool output") + admitted = _admit_provider_request(admission, request) + + first = _egress(egress, request, admitted.attestation) + retry = _egress(egress, request, admitted.attestation) + + assert admitted.attestation is not None + assert first.decision.value == "allow" + assert retry.decision.value == "allow" + + +@pytest.mark.parametrize( + ("mutation", "reason_code"), + [ + ( + lambda body: body["messages"][1].update({"content": "changed prompt"}), + "context_hash_mismatch", + ), + ( + lambda body: body["messages"].append({"role": "user", "content": "extra"}), + "entry_count_mismatch", + ), + (lambda body: body["messages"].pop(), "entry_count_mismatch"), + ], +) +def test_chat_context_tampering_is_denied(mutation, reason_code: str) -> None: + admission, egress, _ = _processors() + request = _provider_request("use the tool", tool_result="safe tool output") + admitted = _admit_provider_request(admission, request) + body = json.loads(request.body) + mutation(body) + changed = request.model_copy( + update={ + "body": json.dumps( + body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + result = _egress(egress, changed, admitted.attestation) + + assert result.reason_code == reason_code + + +def test_responses_earlier_entry_tampering_is_denied() -> None: + admission, egress, _ = _processors() + request = _responses_request("use the tool", tool_result="safe tool output") + admitted = _admit_provider_request(admission, request) + + result = _egress( + egress, + _responses_request("changed prompt", tool_result="safe tool output"), + admitted.attestation, + ) + + assert result.reason_code == "context_hash_mismatch" + + +def test_provider_context_redaction_binds_only_the_replacement() -> None: + admission, egress, _ = _processors() + original = _provider_request(f"hide {REDACT_TEXT} please") + admitted = _admit_provider_request(admission, original) + + assert admitted.decision is AdmissionDecision.REPLACE + assert admitted.attestation is not None + assert admitted.replacement_body is not None + replacement = PiProviderContextV1.model_validate_json( + admitted.replacement_body, strict=True + ) + replaced = _provider_request(replacement.entries[0].text) + + assert _egress(egress, replaced, admitted.attestation).decision.value == "allow" + assert ( + _egress(egress, original, admitted.attestation).reason_code + == "context_hash_mismatch" + ) + + +def test_restored_context_with_denied_text_is_blocked_at_send_time() -> None: + admission, _, _ = _processors() + + denied = _admit_provider_request(admission, _provider_request(DENY_TEXT)) + + assert denied.decision is AdmissionDecision.DENY + assert denied.reason_code == "egress_gate_regex_denied" + + +def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> None: + admission, egress, _ = _processors() + normalized = HttpTarget( + scheme="http", + host="provider.test", + port=443, + method="POST", + path="/v1/chat/completions", + query="", + ) + request = _provider_request("safe", target=normalized) + admitted = _admit_provider_request(admission, request) + + allowed = _egress(egress, request, admitted.attestation) + wrong_host = _egress( + egress, + _provider_request( + "safe", target=normalized.model_copy(update={"host": "other.test"}) + ), + admitted.attestation, + ) + + assert allowed.decision.value == "allow" + assert wrong_host.reason_code == "attestation_context_mismatch" + + +def test_append_time_allow_returns_no_attestation() -> None: + admission, _, _ = _processors() + + admitted = _admit(admission, _user("safe")) + + assert admitted.decision is AdmissionDecision.ALLOW + assert admitted.attestation is None + + +def test_tool_result_denial_redaction_and_images_fail_closed() -> None: + admission, _, _ = _processors() + + denied = _admit(admission, _tool_result(DENY_TEXT)) + redacted = _admit(admission, _tool_result(REDACT_TEXT)) + image = _admit(admission, _tool_result("", image=True)) + + assert denied.decision is AdmissionDecision.DENY + assert denied.attestation is None + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + redacted_tool_result = PiToolResultV1.model_validate_json( + redacted.replacement_body, strict=True + ) + assert isinstance(redacted_tool_result.content[0], PiTextContentV1) + assert redacted_tool_result.content[0].text == "[REDACTED]" + assert image.decision is AdmissionDecision.DENY + assert image.reason_code == "admission_contract_invalid" + + +@pytest.mark.parametrize( + "origin", + ["user", "compaction_summary", "branch_summary", "extension_message"], +) +def test_text_message_origins_allow_replace_and_deny(origin) -> None: + admission, _, _ = _processors() + + allowed = _admit(admission, _message("safe", origin=origin)) + redacted = _admit(admission, _message(REDACT_TEXT, origin=origin)) + denied = _admit(admission, _message(DENY_TEXT, origin=origin)) + + assert allowed.decision is AdmissionDecision.ALLOW + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + replacement = PiMessageV1.model_validate_json( + redacted.replacement_body, strict=True + ) + assert replacement.origin == origin + assert replacement.text == "[REDACTED]" + assert denied.decision is AdmissionDecision.DENY + + +def test_text_message_binding_rejects_a_different_origin() -> None: + admission, _, _ = _processors() + value = _message("safe", origin="branch_summary") + + result = admission.process( + HarnessAdmissionRequest( + request_body=canonical_json_bytes(value), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.COMPACTION_SUMMARY), + timeout=Timeout.from_seconds(1), + ) + + assert result.reason_code == "admission_contract_invalid" + + +def test_assistant_message_allows_text_replacement_and_denial() -> None: + admission, _, _ = _processors() + + allowed = _admit(admission, _assistant("safe")) + redacted = _admit(admission, _assistant(REDACT_TEXT)) + denied = _admit(admission, _assistant(DENY_TEXT)) + + assert allowed.decision is AdmissionDecision.ALLOW + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + replacement = PiAssistantMessageV1.model_validate_json( + redacted.replacement_body, strict=True + ) + assert replacement.text == "[REDACTED]" + assert replacement.tool_calls == _assistant("safe").tool_calls + assert denied.decision is AdmissionDecision.DENY + + +def test_assistant_message_accepts_javascript_number_serialization() -> None: + admission, _, _ = _processors() + body = ( + b'{"schema_version":"openshell.pi-assistant-message.v1","text":"safe",' + b'"tool_calls":[{"arguments":{"threshold":1e-7},"id":"call-1",' + b'"name":"read"}]}' + ) + + result = admission.process( + HarnessAdmissionRequest( + request_body=body, + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.ASSISTANT_MESSAGE), + timeout=Timeout.from_seconds(1), + ) + + assert result.decision is AdmissionDecision.ALLOW + + +def test_assistant_message_rejects_tool_call_mutation() -> None: + admission, _, _ = _processors() + + result = _admit(admission, _assistant("safe", arguments={"path": REDACT_TEXT})) + + assert result.decision is AdmissionDecision.DENY + assert result.reason_code == "admission_contract_invalid" + + +def test_bash_execution_allows_output_replacement_and_denial() -> None: + admission, _, _ = _processors() + + allowed = _admit(admission, _bash("safe")) + redacted = _admit(admission, _bash(REDACT_TEXT)) + denied = _admit(admission, _bash(DENY_TEXT)) + + assert allowed.decision is AdmissionDecision.ALLOW + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + replacement = PiBashExecutionV1.model_validate_json( + redacted.replacement_body, strict=True + ) + assert replacement.output == "[REDACTED]" + assert (replacement.command, replacement.exit_code) == ("printf safe", 0) + assert denied.decision is AdmissionDecision.DENY + + +def test_bash_execution_rejects_command_mutation() -> None: + admission, _, _ = _processors() + + result = _admit(admission, _bash("safe", command=f"printf {REDACT_TEXT}")) + + assert result.decision is AdmissionDecision.DENY + assert result.reason_code == "admission_contract_invalid" + + +def test_denial_returns_no_attestation_or_replacement() -> None: + admission, _, _ = _processors() + + denied = _admit(admission, _user(f"do not persist {DENY_TEXT}")) + + assert denied.decision is AdmissionDecision.DENY + assert denied.attestation is None + assert denied.replacement_body is None + + +@pytest.mark.parametrize( + "context_update", + [{"harness": "unknown"}, {"schema_version": "openshell.unknown.v1"}], +) +def test_unknown_harness_binding_fails_closed(context_update: dict[str, str]) -> None: + admission, _, _ = _processors() + result = admission.process( + HarnessAdmissionRequest( + request_body=canonical_json_bytes(_user("safe")), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.USER_MESSAGE).model_copy(update=context_update), + timeout=Timeout.from_seconds(1), + ) + + assert result.reason_code == "admission_contract_invalid" + + +def test_oversized_redaction_attempt_fails_before_attestation_issuance() -> None: + admission, _, _ = _processors(replacement_template="x" * 1024) + + denied = _admit( + admission, + _user(REDACT_TEXT * (MAX_ADMISSION_BODY_BYTES // 1024 + 1)), + ) + + assert denied.decision is AdmissionDecision.DENY + assert denied.reason_code == "egress_gate_limit_exceeded" + assert denied.attestation is None + + +def test_malformed_duplicate_and_expired_admission_fail_closed() -> None: + admission, _, _ = _processors() + provenance = AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ) + + def admit_body(body: bytes, timeout: Timeout | None = None): + return admission.process( + HarnessAdmissionRequest(request_body=body, provenance=provenance), + _context(AdmissionHook.USER_MESSAGE), + timeout=timeout or Timeout.from_seconds(1), + ) + + malformed = admit_body(b"{") + missing_origin = admit_body( + b'{"schema_version":"openshell.pi-message.v1","text":"safe"}' + ) + duplicate = admit_body( + b'{"origin":"user","schema_version":"openshell.pi-message.v1",' + b'"schema_version":"openshell.pi-message.v1","text":"safe"}' + ) + over_depth = admit_body(b"[" * 129 + b"0" + b"]" * 129) + expired = admit_body(b"{}", Timeout(deadline=0.0)) + + assert malformed.reason_code == "admission_contract_invalid" + assert missing_origin.reason_code == "admission_contract_invalid" + assert duplicate.reason_code == "admission_contract_invalid" + assert over_depth.reason_code == "admission_unavailable" + assert expired.reason_code == "admission_unavailable" + + +def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() -> None: + admission, egress, _ = _processors() + request = _provider_request("safe") + admitted = _admit_provider_request(admission, request) + malformed = request.model_copy(update={"body": b"{"}) + provider_body = json.loads(request.body) + provider_body["reasoning_effort"] = "medium" + with_reasoning = request.model_copy( + update={ + "body": json.dumps( + provider_body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + malformed_result = _egress(egress, malformed, admitted.attestation) + reasoning_result = _egress(egress, with_reasoning, admitted.attestation) + + assert malformed_result.reason_code == "provider_shape_unsupported" + assert reasoning_result.decision.value == "allow" + + +@pytest.mark.parametrize( + "mutation", + [ + lambda body: body.update({"max_completion_tokens": 128}), + lambda body: body.update({"store": None}), + lambda body: body["tools"][0]["function"].update({"strict": None}), + ], +) +def test_mixed_or_null_chat_compatibility_fields_fail_closed(mutation) -> None: + admission, egress, _ = _processors() + request = _provider_request("safe") + admitted = _admit_provider_request(admission, request) + body = json.loads(request.body) + mutation(body) + malformed = request.model_copy( + update={ + "body": json.dumps( + body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + result = _egress(egress, malformed, admitted.attestation) + + assert result.reason_code == "provider_shape_unsupported" + + +@pytest.mark.parametrize( + "mutation", + [ + lambda body: body["messages"][2].update({"reasoning_content": None}), + lambda body: body["messages"][2].update({"unknown_replay_field": "value"}), + ], +) +def test_qwen_replay_fields_fail_closed_unless_explicitly_supported(mutation) -> None: + admission, egress, _ = _processors() + body = json.loads(json.dumps(_PI_CHAT_FIXTURES["qwen"])) + original = _provider_request("unused").model_copy( + update={"body": json.dumps(body, separators=(",", ":")).encode()} + ) + admitted = _admit_provider_request(admission, original) + mutation(body) + request = _provider_request("unused").model_copy( + update={"body": json.dumps(body, separators=(",", ":")).encode()} + ) + + result = _egress(egress, request, admitted.attestation) + + assert result.reason_code == "provider_shape_unsupported" + + +def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: + admission, egress, _ = _processors() + request = _provider_request( + "safe", + headers=(HttpHeader(name=RECEIPT_HEADER, value="eg1.untrusted"),), + ) + admitted = _admit_provider_request(admission, request) + + result = _egress(egress, request, admitted.attestation) + + assert result.reason_code == "reserved_header_present" diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs new file mode 100644 index 00000000..31107c8b --- /dev/null +++ b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; + +import { createOpenShellContextAdmission } from "../../examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts"; + +const HANDLE_HEADER = "x-openshell-agent-admission-handle"; +const ADMISSION_TOKEN = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const ENTRY_VECTORS = JSON.parse( + readFileSync(new URL("../admission/fixtures/context-entries.json", import.meta.url), "utf8"), +); + +function user(text, timestamp) { + return { role: "user", content: [{ type: "text", text }], timestamp }; +} + +function toolResult(text, isError = false) { + return { + role: "toolResult", + toolCallId: "call-1", + toolName: "bash", + content: [{ type: "text", text }], + isError, + timestamp: 2, + }; +} + +function assistant(text) { + return { + role: "assistant", + content: [ + { type: "thinking", thinking: "keep reasoning" }, + { type: "text", text }, + { type: "toolCall", id: "call-1", name: "read", arguments: { path: "safe" } }, + ], + }; +} + +async function admittedContext(admission, context) { + const result = await admission.admitProviderContext(context); + assert.equal(result.action, "allow"); + return result.context ?? context; +} + +describe("OpenShell context admission adapter", () => { + it("selects the handle for the exact provider context", async () => { + let providerCalls = 0; + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + ADMISSION_TOKEN, + async (_url, init) => { + assert.equal(new Headers(init?.headers).get("authorization"), `Bearer ${ADMISSION_TOKEN}`); + const request = JSON.parse(String(init?.body)); + const requestBody = Buffer.from(request.request_body_b64, "base64").toString(); + const envelope = JSON.parse(requestBody); + if (request.hook !== "provider_context") { + return new Response(JSON.stringify({ decision: "allow" })); + } + providerCalls += 1; + assert.equal(envelope.schema_version, "openshell.pi-provider-context.v1"); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.entries.length}` })); + }, + ); + const current = user("current", 1); + const queued = { role: "user", content: "queued", timestamp: 2 }; + + assert.equal((await admission.admitMessage(current, { origin: "user", source: "interactive" })).action, "allow"); + assert.equal((await admission.admitMessage(queued, { origin: "user", source: "interactive" })).action, "allow"); + + const currentHeaders = await admission.transformProviderHeaders( + {}, + await admittedContext(admission, { messages: [current], tools: [] }), + ); + const queuedHeaders = await admission.transformProviderHeaders( + {}, + await admittedContext(admission, { messages: [current, queued], tools: [] }), + ); + + assert.equal(currentHeaders[HANDLE_HEADER], "handle:1"); + assert.equal(queuedHeaders[HANDLE_HEADER], "handle:2"); + assert.equal(providerCalls, 2); + }); + + it("uses an admitted replacement for the outbound handle", async () => { + const replacement = new TextEncoder().encode( + JSON.stringify({ + schema_version: "openshell.pi-provider-context.v1", + entries: [ + { role: "user", text: "[REDACTED]" }, + { role: "tool", tool_call_id: "call-1", text: "[TOOL REDACTED]" }, + ], + }), + ); + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + ADMISSION_TOKEN, + async () => + new Response( + JSON.stringify({ + decision: "allow", + handle: "replacement-handle", + replacement_body_b64: Buffer.from(replacement).toString("base64"), + }), + ), + ); + const context = await admittedContext(admission, { + messages: [user("secret", 1), toolResult("tool secret")], + tools: [], + }); + const headers = await admission.transformProviderHeaders({}, context); + + assert.deepEqual(context.messages[0].content, [{ type: "text", text: "[REDACTED]" }]); + assert.deepEqual(context.messages[1].content, [{ type: "text", text: "[TOOL REDACTED]" }]); + assert.equal(headers[HANDLE_HEADER], "replacement-handle"); + }); + + it("attests failed tool results", async () => { + const hooks = []; + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + ADMISSION_TOKEN, + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + hooks.push(request.hook); + return new Response(JSON.stringify({ + decision: "allow", + ...(request.hook === "provider_context" ? { handle: "context-handle" } : {}), + })); + }, + ); + const prompt = user("run command", 1); + const failed = toolResult("Command exited with code 2", true); + + await admission.admitMessage(prompt, { origin: "user", source: "interactive" }); + await admission.admitMessage(failed, { origin: "tool_result" }); + const headers = await admission.transformProviderHeaders( + {}, + await admittedContext(admission, { messages: [prompt, failed], tools: [] }), + ); + + assert.equal(headers[HANDLE_HEADER], "context-handle"); + assert.deepEqual(hooks, ["user_message", "tool_result", "provider_context"]); + }); + + it("maps every Pi message origin to its exact hook and envelope", async () => { + const cases = [ + { + origin: "user", + message: user("user text", 1), + hook: "user_message", + schema: "openshell.pi-message.v1", + expected: { origin: "user", text: "user text" }, + }, + { + origin: "tool_result", + message: toolResult("tool text"), + hook: "tool_result", + schema: "openshell.pi-tool-result.v1", + expected: { tool_call_id: "call-1", tool_name: "bash", is_error: false }, + }, + { + origin: "assistant", + message: assistant("assistant text"), + hook: "assistant_message", + schema: "openshell.pi-assistant-message.v1", + expected: { text: "assistant text", tool_calls: [{ id: "call-1", name: "read", arguments: { path: "safe" } }] }, + }, + { + origin: "compaction_summary", + message: { role: "compactionSummary", summary: "compact text" }, + hook: "compaction_summary", + schema: "openshell.pi-message.v1", + expected: { origin: "compaction_summary", text: "compact text" }, + }, + { + origin: "branch_summary", + message: { role: "branchSummary", summary: "branch text" }, + hook: "branch_summary", + schema: "openshell.pi-message.v1", + expected: { origin: "branch_summary", text: "branch text" }, + }, + { + origin: "extension_message", + message: { role: "custom", content: "extension text" }, + hook: "extension_message", + schema: "openshell.pi-message.v1", + expected: { origin: "extension_message", text: "extension text" }, + }, + { + origin: "bash_execution", + message: { role: "bashExecution", command: "printf safe", output: "bash text", exitCode: 0 }, + hook: "bash_execution", + schema: "openshell.pi-bash-execution.v1", + expected: { command: "printf safe", output: "bash text", exit_code: 0 }, + }, + ]; + const observed = []; + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + ADMISSION_TOKEN, + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + const envelope = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); + observed.push({ request, envelope }); + return new Response(JSON.stringify({ decision: "allow" })); + }, + ); + + for (const item of cases) { + assert.equal((await admission.admitMessage(item.message, { origin: item.origin })).action, "allow"); + } + assert.equal(observed.length, cases.length); + for (const [index, item] of cases.entries()) { + assert.equal(observed[index].request.hook, item.hook); + assert.equal(observed[index].request.schema_version, item.schema); + assert.equal(observed[index].envelope.schema_version, item.schema); + for (const [key, value] of Object.entries(item.expected)) { + assert.deepEqual(observed[index].envelope[key], value); + } + } + assert.deepEqual(Object.keys(observed[2].envelope), ["schema_version", "text", "tool_calls"]); + assert.deepEqual(Object.keys(observed[2].envelope.tool_calls[0]), ["arguments", "id", "name"]); + assert.deepEqual(Object.keys(observed[6].envelope), ["command", "exit_code", "output", "schema_version"]); + }); + + it("applies replacements only to the origin's replaceable text", async () => { + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + ADMISSION_TOKEN, + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + const envelope = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); + if ("text" in envelope) envelope.text = "[REDACTED]"; + if ("output" in envelope) envelope.output = "[REDACTED]"; + return new Response( + JSON.stringify({ + decision: "allow", + replacement_body_b64: Buffer.from(JSON.stringify(envelope)).toString("base64"), + }), + ); + }, + ); + + const summary = await admission.admitMessage( + { role: "compactionSummary", summary: "secret" }, + { origin: "compaction_summary" }, + ); + const bash = await admission.admitMessage( + { role: "bashExecution", command: "printf safe", output: "secret", exitCode: 7 }, + { origin: "bash_execution" }, + ); + const reply = await admission.admitMessage(assistant("secret"), { origin: "assistant" }); + + assert.equal(summary.message.summary, "[REDACTED]"); + assert.deepEqual( + { command: bash.message.command, output: bash.message.output, exitCode: bash.message.exitCode }, + { command: "printf safe", output: "[REDACTED]", exitCode: 7 }, + ); + assert.deepEqual(reply.message.content, [ + { type: "thinking", thinking: "keep reasoning" }, + { type: "text", text: "[REDACTED]" }, + { type: "toolCall", id: "call-1", name: "read", arguments: { path: "safe" } }, + ]); + }); + + it("matches the shared context-entry vectors", async () => { + for (const vector of ENTRY_VECTORS.cases) { + let observed; + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + ADMISSION_TOKEN, + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + observed = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); + return new Response(JSON.stringify({ decision: "allow", handle: "context-handle" })); + }, + ); + + await admission.admitProviderContext(vector.context); + + assert.equal(observed.schema_version, "openshell.pi-provider-context.v1"); + assert.deepEqual(observed.entries, vector.entries, vector.name); + } + }); + + it("denies provider contexts containing images before calling the bridge", async () => { + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + ADMISSION_TOKEN, + async () => { throw new Error("bridge should not be called"); }, + ); + + const result = await admission.admitProviderContext({ + messages: [{ role: "user", content: [{ type: "image", data: "AA==", mimeType: "image/png" }] }], + tools: [], + }); + + assert.equal(result.action, "deny"); + }); + + it("fails closed when provider-only context is denied", async () => { + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + ADMISSION_TOKEN, + async () => new Response(JSON.stringify({ decision: "deny", reason_code: "policy_denied" })), + ); + + assert.deepEqual(await admission.admitProviderContext({ messages: [user("summary", 1)], tools: [] }), { + action: "deny", + reason: "OpenShell denied this context addition (policy_denied)", + }); + }); +}); diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 1ecbec0e..907b871d 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -13,6 +14,12 @@ from google.protobuf import empty_pb2, json_format, message_factory from google.protobuf.message import Message +from egress_gate.admission import ( + PiMessageV1, + PiProviderContextV1, + UserContextEntryV1, + canonical_json_bytes, +) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc from egress_gate.errors import EgressGateError, ErrorCode @@ -153,6 +160,149 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N assert denied.reason_code == "egress_gate_regex_denied" +@pytest.mark.asyncio +async def test_generated_stub_returns_no_attestation_for_append_time_allow() -> None: + body = canonical_json_bytes( + PiMessageV1( + schema_version="openshell.pi-message.v1", origin="user", text="safe" + ) + ) + request = pb2.AgentConversationEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, + context=pb2.RequestContext(request_id="admission-1", sandbox_id="sandbox"), + config=_config(action_kind="detect"), + target=pb2.AgentConversationTarget( + harness="pi", + harness_version="sdk-v1", + hook="user_message", + schema_version="openshell.pi-message.v1", + scheme="https", + host="provider.invalid", + port=443, + path="/v1/chat/completions", + ), + middleware_name="pi-egress", + session_id="session-1", + turn_id="submission-1", + request_body=body, + ) + middleware = EgressGateMiddleware( + create_builtin_registry(), require_agent_attestation=True + ) + async with _running_stub(middleware) as (stub, _): + response = await stub.EvaluateAgentConversation(request) + + assert response.decision == pb2.DECISION_ALLOW + assert response.attestation == b"" + assert response.has_replacement_body is False + assert not response.metadata + + +@pytest.mark.asyncio +async def test_agent_admission_is_unavailable_when_managed_mode_is_off() -> None: + request = pb2.AgentConversationEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT + ) + middleware = EgressGateMiddleware(create_builtin_registry()) + async with _running_stub(middleware) as (stub, _): + response = await stub.EvaluateAgentConversation(request) + + assert response.decision == pb2.DECISION_DENY + assert response.reason_code == "admission_unavailable" + + +@pytest.mark.asyncio +async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: + pi_body = canonical_json_bytes( + PiProviderContextV1( + schema_version="openshell.pi-provider-context.v1", + entries=(UserContextEntryV1(role="user", text="safe"),), + ) + ) + admission = pb2.AgentConversationEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, + context=pb2.RequestContext(request_id="admission-2", sandbox_id="sandbox"), + config=_config(action_kind="detect"), + target=pb2.AgentConversationTarget( + harness="pi", + harness_version="sdk-v1", + hook="provider_context", + schema_version="openshell.pi-provider-context.v1", + scheme="https", + host="provider.invalid", + port=443, + path="/v1/chat/completions", + ), + middleware_name="pi-egress", + session_id="session-1", + turn_id="submission-2", + request_body=pi_body, + ) + provider_body = json.dumps( + { + "model": "fixture-model", + "messages": [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "safe"}, + ], + "temperature": 0, + "max_completion_tokens": 128, + "tool_choice": "auto", + "stream": True, + "stream_options": {"include_usage": True}, + "store": False, + "prompt_cache_key": "session-1", + }, + separators=(",", ":"), + ).encode() + middleware = EgressGateMiddleware( + create_builtin_registry(), require_agent_attestation=True + ) + async with _running_stub(middleware) as (stub, _): + admitted = await stub.EvaluateAgentConversation(admission) + network = _evaluation(provider_body, action_kind="detect") + network.context.request_id = "network-2" + network.target.host = "provider.invalid" + network.target.path = "/v1/chat/completions" + network.middleware_name = "pi-egress" + network.headers.append( + pb2.HttpHeader(name="content-type", value="application/json") + ) + network.agent_attestation = admitted.attestation + allowed = await stub.EvaluateHttpRequest(network) + missing = _evaluation(provider_body, action_kind="detect") + missing.target.host = "provider.invalid" + missing.target.path = "/v1/chat/completions" + missing.middleware_name = "pi-egress" + missing.headers.append( + pb2.HttpHeader(name="content-type", value="application/json") + ) + denied = await stub.EvaluateHttpRequest(missing) + + assert allowed.decision == pb2.DECISION_ALLOW + assert not allowed.header_mutations + assert denied.decision == pb2.DECISION_DENY + assert denied.reason_code == "attestation_missing" + + +@pytest.mark.asyncio +async def test_unmanaged_http_rejects_the_reserved_header() -> None: + middleware = EgressGateMiddleware(create_builtin_registry()) + request = _evaluation(b"safe", action_kind="detect") + request.headers.append( + pb2.HttpHeader( + name="X-OpenShell-Middleware-Egress-Receipt", + value="eg1.untrusted", + ) + ) + + async with _running_stub(middleware) as (stub, _): + response = await stub.EvaluateHttpRequest(request) + + assert response.decision == pb2.DECISION_DENY + assert response.reason_code == "reserved_header_present" + + @pytest.mark.asyncio async def test_generated_stub_returns_three_gate_progressive_redaction() -> None: middleware = EgressGateMiddleware(create_builtin_registry()) diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 22cfe86d..f9636a9f 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -24,6 +24,7 @@ DEFAULT_DENY_REASON_CODE, LIMIT_REASON, LIMIT_REASON_CODE, + MAX_AGENT_ATTESTATION_BYTES, MAX_BODY_BYTES, MAX_PROTO_CONFIG_BYTES, MAX_PROTO_CONTEXT_BYTES, @@ -130,6 +131,35 @@ def test_manifest_leaves_the_gateway_rpc_timeout_to_the_operator() -> None: assert manifest.bindings[0].timeout == "" +def test_managed_manifest_advertises_every_pi_admission_binding() -> None: + middleware = EgressGateMiddleware( + create_builtin_registry(), require_agent_attestation=True + ) + try: + manifest = asyncio.run(middleware.Describe(object(), Mock())) + finally: + asyncio.run(middleware.close()) + + agent_bindings = [ + binding + for binding in manifest.bindings + if binding.operation == pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION + ] + assert [ + (binding.harness, binding.hook, binding.schema_version) + for binding in agent_bindings + ] == [ + ("pi", "user_message", "openshell.pi-message.v1"), + ("pi", "compaction_summary", "openshell.pi-message.v1"), + ("pi", "branch_summary", "openshell.pi-message.v1"), + ("pi", "extension_message", "openshell.pi-message.v1"), + ("pi", "tool_result", "openshell.pi-tool-result.v1"), + ("pi", "assistant_message", "openshell.pi-assistant-message.v1"), + ("pi", "bash_execution", "openshell.pi-bash-execution.v1"), + ("pi", "provider_context", "openshell.pi-provider-context.v1"), + ] + + def test_copied_proto_remains_the_current_five_field_finding_contract() -> None: evaluation = pb2.HttpRequestEvaluation() finding = pb2.Finding() @@ -220,6 +250,13 @@ def test_evaluation_enforces_exact_encoded_transport_boundaries() -> None: with pytest.raises(EgressGateError): servicer_module._validate_evaluation_envelope(request) + request = _request(body=b"") + request.agent_attestation = b"x" * MAX_AGENT_ATTESTATION_BYTES + servicer_module._validate_evaluation_envelope(request) + request.agent_attestation += b"x" + with pytest.raises(EgressGateError): + servicer_module._validate_evaluation_envelope(request) + def test_request_adapter_builds_the_full_domain_request() -> None: domain = servicer_module._request_from_proto(_request(b"bytes")) @@ -536,12 +573,16 @@ def record_serialization( @pytest.mark.asyncio @pytest.mark.parametrize( - ("action_kind", "expected_source"), - (("detect", "pipeline_default"), ("deny", "gate")), + ("action_kind", "expected_source", "expected_reason"), + ( + ("detect", "pipeline_default", None), + ("deny", "gate", "egress_gate_regex_denied"), + ), ) async def test_evaluation_log_records_decision_source( action_kind: str, expected_source: str, + expected_reason: str | None, caplog: pytest.LogCaptureFixture, ) -> None: middleware = EgressGateMiddleware(create_builtin_registry()) @@ -559,6 +600,7 @@ async def test_evaluation_log_records_decision_source( if item.message.startswith("egress_gate_evaluation") ) assert getattr(record, "decision_source_kind", None) == expected_source + assert getattr(record, "reason_code", None) == expected_reason @pytest.mark.asyncio diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 7ee04eec..1a354aab 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -74,8 +74,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, + require_agent_attestation: bool = False, ) -> None: - del registry + del registry, require_agent_attestation self.timeout_middleware_processing = timeout_middleware_processing def serve_sync(self, listen: str) -> None: @@ -102,6 +103,9 @@ def serve_sync(self, listen: str) -> None: assert "s for seconds or ms for milliseconds" in serve_help assert "Minimum 10ms" in serve_help assert "RPC timeout" in serve_help + assert "--require-agent-attestation" in serve_help + assert "--json-log" in serve_help + assert "--require-" + "pi-attestation" not in serve_help evaluate_help = CliRunner().invoke(app, ["evaluate", "--help"]) assert evaluate_help.exit_code == 0, evaluate_help.output @@ -288,6 +292,17 @@ def test_openshell_example_policies_use_valid_gate_configuration( assert embedded_config == standalone_config +def test_pi_admission_policy_uses_valid_gate_configuration() -> None: + project_dir = Path(__file__).parents[1] + policy_path = project_dir / "examples/pi-attested-admission/policy.yaml" + policy = yaml.safe_load(policy_path.read_text()) + middleware = policy["network_middlewares"]["pi_egress_gate"] + + assert middleware["middleware"] == "pi-egress" + assert len(middleware["middleware"]) <= MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES + create_builtin_registry().validate_config(middleware["config"]) + + @pytest.mark.parametrize( ("example_directory", "name"), [ @@ -535,8 +550,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, + require_agent_attestation: bool = False, ) -> None: - del registry, timeout_middleware_processing + del registry, timeout_middleware_processing, require_agent_attestation def serve_sync(self, listen: str) -> None: calls.append(listen) diff --git a/projects/egress-gate/tests/test_logging.py b/projects/egress-gate/tests/test_logging.py index 4070c5b2..1b55ed41 100644 --- a/projects/egress-gate/tests/test_logging.py +++ b/projects/egress-gate/tests/test_logging.py @@ -6,6 +6,7 @@ from __future__ import annotations import ast +import json import logging import re from collections.abc import Iterator @@ -18,6 +19,7 @@ DEFAULT_LOGGING_CONFIG, ColorMode, LoggingConfig, + configure_json_log, configure_logging, get_logger, reset_logging, @@ -124,6 +126,39 @@ def test_configure_logging_accepts_native_log_levels() -> None: ) +def test_configure_json_log_writes_only_content_safe_fields(tmp_path: Path) -> None: + path = tmp_path / "evaluations.jsonl" + configure_logging() + configure_json_log(path) + + logging.getLogger("egress_gate.service").info( + "egress_gate_evaluation", + extra={ + "event": "egress_gate_evaluation", + "request_id": "request-1", + "duration_ms": 1.25, + "action": "deny", + "reason_code": "attestation_missing", + "finding_count": 0, + "decision_source_kind": "gate", + "error_code": None, + "request_body": "must not be logged", + }, + ) + + record = json.loads(path.read_text()) + assert record == { + "event": "egress_gate_evaluation", + "request_id": "request-1", + "duration_ms": 1.25, + "action": "deny", + "reason_code": "attestation_missing", + "finding_count": 0, + "decision_source_kind": "gate", + "error_code": None, + } + + def test_configure_logging_replaces_its_previous_handler() -> None: first_stream = StringIO() second_stream = StringIO() diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py new file mode 100644 index 00000000..26a56aac --- /dev/null +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -0,0 +1,519 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import os +import subprocess +import tomllib +from pathlib import Path + +import yaml + + +def test_pi_openshell_context_admission_adapter() -> None: + project_dir = Path(__file__).parents[1] + subprocess.run( + [ + "node", + "--test", + str(project_dir / "tests/js/openshell-context-admission.test.mjs"), + ], + check=True, + cwd=project_dir, + ) + + +def test_pi_example_can_print_each_action_without_running_it( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + pi_repo = tmp_path / "pi" + openshell_repo = tmp_path / "OpenShell" + pack_dir = tmp_path / "pack" + runtime_dir = tmp_path / "runtime" + models_path = project_dir / "examples/pi-attested-admission/models.json" + environment = os.environ | { + "PI_REPO": str(pi_repo), + "OPENSHELL_REPO": str(openshell_repo), + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODELS_PATH": str(models_path), + "PI_EGRESS_PACK_DIR": str(pack_dir), + "PI_EGRESS_RUNTIME_DIR": str(runtime_dir), + "PI_EGRESS_ENV_FILE": "", + "PI_WORKSPACE_PATH": str(tmp_path / "workspace"), + } + + results = [ + subprocess.run( + ["bash", str(script), "--print", action], + check=True, + capture_output=True, + env=environment, + text=True, + ) + for action in ( + "prepare", + "serve", + "gateway", + "reset", + "launch", + "verify", + "cleanup", + ) + ] + output = "\n".join(result.stdout for result in results) + normalized_output = " ".join(output.replace("\\\n", " ").split()) + + assert "npm run build:offline" in output + assert "earendil-works-pi-agent-core-VERSION.tgz" in output + assert "earendil-works-pi-coding-agent-VERSION.tgz" in output + assert "npm pack --workspace @earendil-works/pi-agent-core" in output + assert "npm pack --workspace @earendil-works/pi-coding-agent" in output + assert "git clone --branch johnny/before-user-message-commit" in output + assert "git clone --branch openshell/pi-egress-admission" in output + assert ( + "git pull --no-rebase --ff-only origin johnny/before-user-message-commit" + in output + ) + assert ( + "git pull --no-rebase --ff-only origin openshell/pi-egress-admission" in output + ) + assert "gateway-middleware.toml" in output + assert "OPENSHELL_GATEWAY_CONFIG_FRAGMENT=" in output + assert "gateway-middleware.toml.example" in output + assert "render-runtime-config.mjs" not in output + assert str(models_path) in output + assert "egress-gate --debug serve" in output + assert "--json-log" in output + assert "CARGO_BUILD_JOBS=4" in output + assert "OPENSHELL_GATEWAY_NAME=pi-egress-demo-gateway" in output + assert "--gateway pi-egress-demo-gateway" in output + assert "provider create" in output + assert "provider profile import" in output + assert "provider profile delete pi-attested-model" in output + assert "provider delete pi-model" in output + assert "provider profile update" not in output + assert "--type pi-attested-model" in output + assert "PI_MODEL_API_KEY" in output + assert "OPENAI_API_KEY" not in output + assert "api.openai.com" not in output + assert "sandbox create" in output + assert "--from" in output + assert "pi-attested-admission/sandbox" in output + assert "--detach" in output + assert "--no-git-ignore" in output + assert f"{runtime_dir}/node_modules:/sandbox/pi-runtime" in output + assert f"{runtime_dir}:/sandbox/pi-runtime" not in output + assert f"{models_path}:/sandbox/.pi/agent/models.json" in output + assert "settings.json:/sandbox/.pi/agent/settings.json" in output + assert "sandbox upload" in output + assert "/sandbox/workspace" in output + assert "sandbox exec" in output + assert "sandbox exec --tty" in output + assert "sandbox exec --no-tty" in output + assert "DENY_THIS" in output + assert "/usr/bin/pi" in output + assert "PI_OFFLINE=1" not in output + assert "PI_CODING_AGENT_DIR=" not in output + assert "--no-extensions" not in output + assert "node /sandbox/pi-runtime/integration/openshell-pi.js" in normalized_output + assert "PI_OPENSHELL_CONTEXT_ADMISSION" not in output + assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output + assert "/usr/bin/curl --silent --show-error" in normalized_output + assert "managed-pi" not in output + assert "--extension " not in output + assert "integration/openshell-context-admission.js" in output + assert "integration/openshell-pi.js" in output + assert "Type-check and compile the trusted runtime extension" in output + assert "sandbox delete" in output + assert all(result.stderr == "" for result in results) + assert not pi_repo.exists() + assert not openshell_repo.exists() + assert not pack_dir.exists() + assert not runtime_dir.exists() + + reset_output = results[3].stdout + normalized_reset_output = " ".join(reset_output.replace("\\\n", " ").split()) + assert f"working directory: {tmp_path / 'workspace'}" in reset_output + assert ( + "sandbox upload pi-egress-demo . /sandbox/workspace" in normalized_reset_output + ) + assert ( + f"sandbox upload pi-egress-demo {tmp_path / 'workspace'}" + not in normalized_reset_output + ) + + demo_script = (project_dir / "examples/pi-attested-admission/demo.sh").read_text() + launcher = ( + project_dir / "examples/pi-attested-admission/runtime-extension/openshell-pi.ts" + ).read_text() + assert '"beforeToolResultAppend"' in demo_script + assert "caller_not_authorized" in demo_script + assert "exec env -u PI_MODEL_API_KEY node" not in demo_script + assert '3<<<"$PI_MODEL_API_KEY"' not in demo_script + assert "render-runtime-config.mjs" not in demo_script + assert "OPENSHELL_AGENT_ADMISSION_TOKEN_FD" in launcher + assert "delete process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD" in launcher + assert 'readFileSync(tokenFd, "utf8")' in launcher + assert "closeSync(tokenFd)" in launcher + assert "/^[A-Za-z0-9_-]{43}$/" in launcher + + +def test_pi_example_print_all_is_a_concise_walkthrough() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + + result = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=os.environ + | { + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODELS_PATH": str( + project_dir / "examples/pi-attested-admission/models.json" + ), + "PI_MODEL_API_KEY": "secret-not-printed", + "PI_EGRESS_ENV_FILE": "", + "PI_WORKSPACE_PATH": "/tmp/example-workspace", + }, + text=True, + ) + + assert "Pi attested-admission walkthrough" in result.stdout + assert "Configuration loaded by demo.sh" in result.stdout + assert "Model credential: set (value hidden)" in result.stdout + assert "1. prepare" in result.stdout + assert "7. cleanup" in result.stdout + assert "secret-not-printed" not in result.stdout + assert "working directory:" not in result.stdout + + +def test_pi_example_uses_an_empty_workspace_when_no_path_is_configured() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + environment = { + name: value for name, value in os.environ.items() if name != "PI_WORKSPACE_PATH" + } | { + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODELS_PATH": str( + project_dir / "examples/pi-attested-admission/models.json" + ), + "PI_MODEL_API_KEY": "secret-not-printed", + "PI_EGRESS_ENV_FILE": "", + } + + reset = subprocess.run( + ["bash", str(script), "--print", "reset"], + check=True, + capture_output=True, + env=environment, + text=True, + ) + walkthrough = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment, + text=True, + ) + + assert "sandbox upload" not in reset.stdout + assert "empty /sandbox/workspace" in reset.stdout + assert "Status: ready" in walkthrough.stdout + assert "Pi workspace: empty /sandbox/workspace" in walkthrough.stdout + + +def test_pi_example_launch_preserves_the_prepared_sandbox() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + + result = subprocess.run( + ["bash", str(script), "--print", "launch"], + check=True, + capture_output=True, + env=os.environ | {"PI_EGRESS_ENV_FILE": ""}, + text=True, + ) + + normalized_output = " ".join(result.stdout.replace("\\\n", " ").split()) + assert "sandbox exec --tty" in normalized_output + assert "--workdir /sandbox/workspace" in normalized_output + assert "sandbox delete" not in result.stdout + assert "sandbox create" not in result.stdout + assert "provider delete" not in result.stdout + assert "provider create" not in result.stdout + + +def test_pi_example_cleanup_explains_when_gateway_is_unavailable( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + openshell_repo = tmp_path / "OpenShell" + openshell_cli = openshell_repo / "scripts/bin/openshell" + openshell_cli.parent.mkdir(parents=True) + openshell_cli.write_text( + "#!/bin/sh\necho 'transport error: Connection refused' >&2\nexit 1\n" + ) + openshell_cli.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "cleanup"], + capture_output=True, + env=os.environ + | { + "OPENSHELL_REPO": str(openshell_repo), + "PI_EGRESS_ENV_FILE": "", + }, + text=True, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "OpenShell gateway 'pi-egress-demo-gateway' is not reachable" in ( + result.stderr + ) + assert "Terminal 1: ./demo.sh serve" in result.stderr + assert "Terminal 2: ./demo.sh gateway" in result.stderr + assert "Then run: ./demo.sh cleanup" in result.stderr + assert "Do not run ./demo.sh reset" in result.stderr + assert "transport error" not in result.stderr + + +def test_pi_example_uses_terminal_colors_without_leaking_them_to_redirects() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + environment = { + name: value for name, value in os.environ.items() if name != "NO_COLOR" + } | {"FORCE_COLOR": "1", "PI_EGRESS_ENV_FILE": ""} + + colored = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment, + text=True, + ) + uncolored = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment | {"NO_COLOR": "1"}, + text=True, + ) + assert "\x1b[36m" in colored.stdout + assert "\x1b[" not in uncolored.stdout + + +def test_pi_example_defaults_to_an_ignored_external_workspace() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + + result = subprocess.run( + ["bash", str(script), "--print", "prepare"], + check=True, + capture_output=True, + env={ + name: value + for name, value in os.environ.items() + if name not in {"PI_REPO", "OPENSHELL_REPO", "PI_EGRESS_FORKS_DIR"} + } + | {"PI_EGRESS_ENV_FILE": ""}, + text=True, + ) + + workspace = project_dir / ".workspaces/pi-attested-admission" + assert str(workspace / "pi") in result.stdout + assert str(workspace / "OpenShell") in result.stdout + assert ".workspaces/" in (project_dir / ".gitignore").read_text().splitlines() + + +def test_pi_example_uses_standard_checked_in_configuration() -> None: + project_dir = Path(__file__).parents[1] + example_dir = project_dir / "examples/pi-attested-admission" + models = json.loads((example_dir / "models.json").read_text()) + provider = models["providers"]["attested-provider"] + assert provider["baseUrl"] == "https://inference-api.nvidia.com/v1" + assert provider["apiKey"] == "openshell-proxy" + assert "api" not in provider + configured_models = {model["id"]: model for model in provider["models"]} + assert set(configured_models) == { + "azure/anthropic/claude-opus-5", + "azure/openai/gpt-5.6-sol", + "nvidia/qwen/qwen3.8-flash-next", + } + + opus = configured_models["azure/anthropic/claude-opus-5"] + assert opus["api"] == "openai-completions" + assert opus["reasoning"] is False + assert opus["contextWindow"] == 1_000_000 + assert opus["maxTokens"] == 128_000 + + gpt = configured_models["azure/openai/gpt-5.6-sol"] + assert gpt["api"] == "openai-responses" + assert gpt["reasoning"] is True + assert gpt["contextWindow"] == 1_050_000 + assert gpt["maxTokens"] == 128_000 + + qwen = configured_models["nvidia/qwen/qwen3.8-flash-next"] + assert qwen["api"] == "openai-completions" + assert qwen["reasoning"] is True + assert qwen["contextWindow"] == 262_144 + assert qwen["maxTokens"] == 32_768 + assert qwen["compat"] == { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": False, + "supportsReasoningEffort": True, + "thinkingFormat": "qwen", + } + + settings = json.loads((example_dir / "settings.json").read_text()) + assert settings == { + "defaultProvider": "attested-provider", + "defaultModel": "nvidia/qwen/qwen3.8-flash-next", + "defaultThinkingLevel": "high", + } + + provider_profile = yaml.safe_load( + (example_dir / "provider-profile.yaml").read_text() + ) + assert provider_profile["id"] == "pi-attested-model" + assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] + assert provider_profile["credentials"][0]["delivery"] == "proxy" + assert provider_profile["endpoints"][0]["host"] == "inference-api.nvidia.com" + assert provider_profile["endpoints"][0]["port"] == 443 + + policy = yaml.safe_load((example_dir / "policy.yaml").read_text()) + endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] + assert endpoint["host"] == "inference-api.nvidia.com" + assert endpoint["port"] == 443 + middleware = policy["network_middlewares"]["pi_egress_gate"] + assert middleware["endpoints"]["include"] == ["inference-api.nvidia.com"] + assert set(policy["network_policies"]) == {"model_provider"} + + sandbox_dockerfile = (example_dir / "sandbox/Dockerfile").read_text() + assert "openshell-community/sandboxes/pi:latest" in sandbox_dockerfile + assert "fd-find ripgrep" in sandbox_dockerfile + + gateway_template = (example_dir / "gateway-middleware.toml.example").read_text() + gateway_fragment = tomllib.loads( + gateway_template.replace("YOUR_HOST_IPV4", "192.0.2.10") + ) + registration = gateway_fragment["openshell"]["supervisor"]["middleware"][0] + assert registration["name"] == "pi-egress" + assert registration["grpc_endpoint"] == "http://192.0.2.10:50051" + assert registration["allow_insecure_transport"] is True + assert registration["max_payload_bytes"] == 4 * 1024 * 1024 + + +def test_pi_example_loads_its_env_file_automatically(tmp_path: Path) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + models_path = project_dir / "examples/pi-attested-admission/models.json" + env_file = tmp_path / ".env" + env_file.write_text( + "EGRESS_GATE_HOST_IP=192.0.2.10\n" + f"PI_MODELS_PATH={models_path}\n" + "PI_MODEL_API_KEY=loaded-from-env-file\n" + ) + environment = { + name: value + for name, value in os.environ.items() + if name + not in { + "EGRESS_GATE_HOST_IP", + "PI_MODELS_PATH", + "PI_MODEL_API_KEY", + "PI_WORKSPACE_PATH", + } + } | {"PI_EGRESS_ENV_FILE": str(env_file)} + + result = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment, + text=True, + ) + + assert "Status: ready" in result.stdout + assert "Egress Gate host: 192.0.2.10" in result.stdout + assert f"Pi models file: {models_path}" in result.stdout + assert "Model credential: set (value hidden)" in result.stdout + assert "loaded-from-env-file" not in result.stdout + + +def test_pi_example_reports_all_missing_configuration_before_work( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + environment = { + name: value + for name, value in os.environ.items() + if name + not in { + "EGRESS_GATE_HOST_IP", + "PI_MODELS_PATH", + "PI_MODEL_API_KEY", + "PI_WORKSPACE_PATH", + } + } + environment["PI_EGRESS_ENV_FILE"] = str(tmp_path / "missing.env") + + result = subprocess.run( + ["bash", str(script), "reset"], + capture_output=True, + cwd=tmp_path, + env=environment, + text=True, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "The Pi attested-admission example is not configured." in result.stderr + assert "EGRESS_GATE_HOST_IP" in result.stderr + assert "PI_MODELS_PATH" in result.stderr + assert "PI_MODEL_API_KEY" in result.stderr + assert "PI_WORKSPACE_PATH" not in result.stderr + assert "cp .env.example .env" in result.stderr + assert "git pull" not in result.stderr + + +def test_pi_example_reports_a_missing_compute_backend_before_mise( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + for command in ("docker", "podman"): + stub = tmp_path / command + stub.write_text("#!/bin/sh\nexit 1\n") + stub.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "gateway"], + capture_output=True, + env=os.environ + | { + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "OPENSHELL_DRIVERS": "", + "PI_EGRESS_ENV_FILE": "", + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODELS_PATH": str( + project_dir / "examples/pi-attested-admission/models.json" + ), + "PI_MODEL_API_KEY": "test-key", + }, + text=True, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "No running OpenShell compute backend was detected." in result.stderr + assert "docker info" in result.stderr + assert "podman info" in result.stderr + assert "mise" not in result.stderr diff --git a/projects/egress-gate/uv.lock b/projects/egress-gate/uv.lock index 88ae26e6..ef9e1ad1 100644 --- a/projects/egress-gate/uv.lock +++ b/projects/egress-gate/uv.lock @@ -56,6 +56,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -139,6 +237,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + [[package]] name = "cyclonedx-python-lib" version = "11.11.0" @@ -169,6 +323,7 @@ name = "egress-gate" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "grpcio" }, { name = "protobuf" }, { name = "pydantic" }, @@ -190,6 +345,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cryptography", specifier = ">=50,<51" }, { name = "grpcio", specifier = ">=1.81.1,<2" }, { name = "protobuf", specifier = ">=7.36,<8" }, { name = "pydantic", specifier = ">=2.11,<3" }, @@ -501,6 +657,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4"