From 3dc6e5224a771b8a58b9ac48d672df7897052610 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 14 Sep 2026 16:38:41 +0000 Subject: [PATCH 01/14] feat(pi-admission): add standalone admission example --- .github/workflows/pi-admission.yml | 106 + projects/README.md | 2 + projects/pi-admission/.env.example | 10 + projects/pi-admission/.gitignore | 9 + projects/pi-admission/LICENSE | 203 ++ projects/pi-admission/README.md | 183 ++ projects/pi-admission/bind-sandbox.py | 25 + projects/pi-admission/demo.sh | 124 + projects/pi-admission/middleware/.gitignore | 7 + .../.openshell-middleware-manifest.json | 13 + projects/pi-admission/middleware/Cargo.lock | 1835 ++++++++++++++ projects/pi-admission/middleware/Cargo.toml | 35 + projects/pi-admission/middleware/README.md | 20 + projects/pi-admission/middleware/build.rs | 16 + .../proto/supervisor_middleware.proto | 413 +++ .../pi-admission/middleware/src/admission.rs | 254 ++ projects/pi-admission/middleware/src/auth.rs | 65 + projects/pi-admission/middleware/src/lib.rs | 209 ++ projects/pi-admission/middleware/src/main.rs | 53 + .../pi-admission/middleware/src/policy.rs | 362 +++ .../pi-admission/middleware/src/receipt.rs | 302 +++ projects/pi-admission/models.json.example | 40 + .../pi-admission/pi-harness/package-lock.json | 2219 +++++++++++++++++ projects/pi-admission/pi-harness/package.json | 25 + .../pi-admission/pi-harness/src/admission.ts | 297 +++ projects/pi-admission/pi-harness/src/agent.ts | 421 ++++ projects/pi-admission/pi-harness/src/cli.ts | 46 + projects/pi-admission/pi-harness/src/model.ts | 31 + .../pi-admission/pi-harness/src/network.ts | 11 + .../pi-admission/pi-harness/src/session.ts | 288 +++ projects/pi-admission/pi-harness/src/tools.ts | 56 + .../pi-admission/pi-harness/src/verify.ts | 98 + .../pi-admission/pi-harness/test/e2e.test.ts | 165 ++ .../pi-admission/pi-harness/tsconfig.json | 12 + projects/pi-admission/policy.yaml | 52 + projects/pi-admission/prepare.py | 345 +++ projects/pi-admission/project.yaml | 1 + projects/pi-admission/pyproject.toml | 26 + projects/pi-admission/sandbox/Dockerfile | 25 + projects/pi-admission/uv.lock | 285 +++ 40 files changed, 8689 insertions(+) create mode 100644 .github/workflows/pi-admission.yml create mode 100644 projects/pi-admission/.env.example create mode 100644 projects/pi-admission/.gitignore create mode 100644 projects/pi-admission/LICENSE create mode 100644 projects/pi-admission/README.md create mode 100644 projects/pi-admission/bind-sandbox.py create mode 100755 projects/pi-admission/demo.sh create mode 100644 projects/pi-admission/middleware/.gitignore create mode 100644 projects/pi-admission/middleware/.openshell-middleware-manifest.json create mode 100644 projects/pi-admission/middleware/Cargo.lock create mode 100644 projects/pi-admission/middleware/Cargo.toml create mode 100644 projects/pi-admission/middleware/README.md create mode 100644 projects/pi-admission/middleware/build.rs create mode 100644 projects/pi-admission/middleware/proto/supervisor_middleware.proto create mode 100644 projects/pi-admission/middleware/src/admission.rs create mode 100644 projects/pi-admission/middleware/src/auth.rs create mode 100644 projects/pi-admission/middleware/src/lib.rs create mode 100644 projects/pi-admission/middleware/src/main.rs create mode 100644 projects/pi-admission/middleware/src/policy.rs create mode 100644 projects/pi-admission/middleware/src/receipt.rs create mode 100644 projects/pi-admission/models.json.example create mode 100644 projects/pi-admission/pi-harness/package-lock.json create mode 100644 projects/pi-admission/pi-harness/package.json create mode 100644 projects/pi-admission/pi-harness/src/admission.ts create mode 100644 projects/pi-admission/pi-harness/src/agent.ts create mode 100644 projects/pi-admission/pi-harness/src/cli.ts create mode 100644 projects/pi-admission/pi-harness/src/model.ts create mode 100644 projects/pi-admission/pi-harness/src/network.ts create mode 100644 projects/pi-admission/pi-harness/src/session.ts create mode 100644 projects/pi-admission/pi-harness/src/tools.ts create mode 100644 projects/pi-admission/pi-harness/src/verify.ts create mode 100644 projects/pi-admission/pi-harness/test/e2e.test.ts create mode 100644 projects/pi-admission/pi-harness/tsconfig.json create mode 100644 projects/pi-admission/policy.yaml create mode 100644 projects/pi-admission/prepare.py create mode 100644 projects/pi-admission/project.yaml create mode 100644 projects/pi-admission/pyproject.toml create mode 100644 projects/pi-admission/sandbox/Dockerfile create mode 100644 projects/pi-admission/uv.lock diff --git a/.github/workflows/pi-admission.yml b/.github/workflows/pi-admission.yml new file mode 100644 index 00000000..b0ae7803 --- /dev/null +++ b/.github/workflows/pi-admission.yml @@ -0,0 +1,106 @@ +name: Pi admission + +"on": + pull_request: + paths: + - ".github/workflows/pi-admission.yml" + - "projects/pi-admission/**" + push: + branches: + - main + paths: + - ".github/workflows/pi-admission.yml" + - "projects/pi-admission/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pi-admission-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python checks + runs-on: ubuntu-latest + defaults: + run: + working-directory: projects/pi-admission + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up uv and Python + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.11.31" + python-version: "3.11" + + - name: Install locked dependencies + run: uv sync --frozen + + - name: Check formatting + run: uv run ruff format --check . + + - name: Lint + run: uv run ruff check . + + rust: + name: Rust middleware + runs-on: ubuntu-latest + defaults: + run: + working-directory: projects/pi-admission/middleware + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Rust + run: | + rustup toolchain install 1.90.0 --profile minimal --no-self-update + rustup default 1.90.0 + rustup component add clippy rustfmt + + - name: Check formatting + run: cargo fmt --check + + - name: Lint + run: cargo clippy --locked --all-targets --all-features -- -D warnings + + - name: Test + run: cargo test --locked + + typescript: + name: TypeScript harness + runs-on: ubuntu-latest + defaults: + run: + working-directory: projects/pi-admission/pi-harness + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "22.22.2" + cache: npm + cache-dependency-path: projects/pi-admission/pi-harness/package-lock.json + + - name: Install locked dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Type-check + run: npm run check + + - name: Build and test + run: | + npm run build + npm test diff --git a/projects/README.md b/projects/README.md index 686bb0bf..6539c0fa 100644 --- a/projects/README.md +++ b/projects/README.md @@ -13,6 +13,8 @@ Current projects: horizons and repeated parallel attempts, starting with GitHub policy review. - `openshell-middleware-manager`: `omm` CLI that creates and updates version-matched Python and Rust OpenShell supervisor middleware projects. +- `pi-admission`: Standalone Pi example that admits content before history writes + and verifies signed approval receipts at OpenShell network egress. - `python-project-template`: Minimal, production-ready Python project scaffold managed with uv. - `reachy-mini-openshell`: Reachy Mini conversation demo for OpenShell. diff --git a/projects/pi-admission/.env.example b/projects/pi-admission/.env.example new file mode 100644 index 00000000..6cace5b2 --- /dev/null +++ b/projects/pi-admission/.env.example @@ -0,0 +1,10 @@ +# Existing HTTPS/mTLS gateway registered in your OpenShell CLI (gateway list). +OPENSHELL_GATEWAY=your-gateway +# Pi admission service hostname or IPv4 address reachable from gateway AND sandbox. +# Use reachable DNS or a LAN IPv4 address; Docker-only names may fail on the host. +PI_ADMISSION_HOST=your-service-host +# Required only when models.json declares more than one model. +# PI_MODEL=openrouter/z-ai/glm-5.3-flash +# Key for the provider in models.json (OpenRouter in the supplied example). +# Never copied into the image. Create an OpenRouter key at https://openrouter.ai/settings/keys. +PI_MODEL_API_KEY=your-provider-key diff --git a/projects/pi-admission/.gitignore b/projects/pi-admission/.gitignore new file mode 100644 index 00000000..fcdd3ebe --- /dev/null +++ b/projects/pi-admission/.gitignore @@ -0,0 +1,9 @@ +# Operator-owned model configuration, like the already ignored .env. +# Keep the previous operator filename ignored during migration. +/model.json +/models.json +.workspaces/ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ diff --git a/projects/pi-admission/LICENSE b/projects/pi-admission/LICENSE new file mode 100644 index 00000000..10a6d3d5 --- /dev/null +++ b/projects/pi-admission/LICENSE @@ -0,0 +1,203 @@ +Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 NVIDIA CORPORATION & AFFILIATES. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/projects/pi-admission/README.md b/projects/pi-admission/README.md new file mode 100644 index 00000000..0a6390fb --- /dev/null +++ b/projects/pi-admission/README.md @@ -0,0 +1,183 @@ +# Standalone Pi admission + +This use-case example runs unmodified Pi in an unmodified OpenShell sandbox and +keeps policy-denied content out of Pi's live conversation and saved JSONL. A +small Rust service makes both decisions: + +1. Pi sends each candidate to authenticated HTTPS admission before publishing + or saving it. +2. Admission allows it unchanged, replaces an `example.com` email with + `[EMAIL]`, or denies an SSN-shaped value. +3. After the full provider context is approved, the service signs a short-lived + receipt over its ordered user/tool text projection, destination, sandbox, + and fixed policy identity. +4. OpenShell calls the same service as pre-credentials middleware. It verifies + the receipt against the intercepted request, applies the policy again, and + removes the private receipt header before provider credentials are attached. + +Blocking only at step 4 would be too late: the denied text could already be in +the local transcript. The two checks protect different boundaries. + +## Demonstration policy + +The fixed policy is compiled once in +[`middleware/src/policy.rs`](middleware/src/policy.rs): + +| Entity | Decision | Synthetic example | +| --- | --- | --- | +| Address ending in `@example.com` | Replace with `[EMAIL]` | `alice@example.com` | +| `NNN-NN-NNNN` digits | Deny | `123-45-6789` | + +Denial is evaluated first. Patterns inspect decoded JSON strings, so JSON +escaping does not bypass them. Text block boundaries are preserved. Content in +executable tool arguments or protected reasoning metadata is denied instead of +rewritten. Egress never rewrites the provider body: it denies any remaining +matching entity. + +These regexes are intentionally incomplete. They do not validate real email +addresses or SSNs and will have both false positives and misses. Use only +synthetic data with this example. + +## Prerequisites + +You need: + +- an existing HTTPS/mTLS OpenShell gateway registered in the `openshell` CLI; +- OpenShell `0.0.116` (the pinned middleware contract) or a compatible release; +- Bash, Python 3.11+, uv 0.11+, Rust 1.90+, Docker, and Node 22 only for local + harness development; +- a provider key with quota for an OpenAI-compatible Chat Completions model. + +The sample catalog uses OpenRouter. Provider use can incur normal model costs. +No keys are copied into the image or committed to this repository. + +## Run it + +From `projects/pi-admission/`: + +```sh +cp .env.example .env +cp models.json.example models.json +# Set OPENSHELL_GATEWAY, PI_ADMISSION_HOST, and PI_MODEL_API_KEY in .env. +./demo.sh prepare +``` + +`PI_ADMISSION_HOST` must be a DNS name or IPv4 address reachable from both the +gateway and sandbox. Do not use `localhost` for container callers. Preparation +discovers the selected gateway's issuer and public Ed25519 key over verified +mTLS, generates a 30-day local service certificate, stages one selected native +Pi model, and builds `pi-admission:local`. Host-owned state is written to +`.workspaces/` with mode `0700`/`0600` defaults. + +Run the service in one terminal: + +```sh +./demo.sh serve +``` + +In another terminal: + +```sh +./demo.sh registration +./demo.sh setup +./demo.sh launch +``` + +`registration` prints the middleware TOML entry. Merge it into the selected +gateway's configuration, install the generated CA path where that gateway can +read it, and restart the gateway before running `setup`. Gateway deployment is +operator-owned; this example does not edit or restart it. + +All actions have a side-effect-free print form which does not load `.env`: + +```sh +./demo.sh --print prepare +./demo.sh --print serve +./demo.sh --print setup +./demo.sh --print launch +./demo.sh --print verify +./demo.sh --print cleanup +``` + +Try these inputs in Pi: + +```text +Hello. Briefly describe what you can do. +Please repeat alice@example.com. +123-45-6789 +Use the write tool to create demo.txt containing a short greeting. +/compact +/new +/quit +``` + +The email becomes `[EMAIL]` before it appears or is saved. The fictitious SSN +shape is rejected and never enters live history or JSONL. The workspace starts +empty, but Pi's project tools remain enabled: the harness admits each tool result +before publishing, saving, or continuing the model turn. Use `/session` to +locate the append-only JSONL under `/sandbox/sessions`. + +Run the separate real-model acceptance workflow with: + +```sh +./demo.sh verify +``` + +It exercises replacement, denial before history mutation, a real tool result, +manual compaction, saved JSONL, and a missing-receipt request. It requires a +running service, gateway, sandbox, and paid provider access; local tests do not +establish live provider compatibility. + +When finished: + +```sh +./demo.sh cleanup +``` + +Cleanup deletes the demo sandbox (including its sessions), provider instances +and profiles. It retains generated host configuration, the manual gateway +registration, and the Docker image. Stop `serve` separately with Ctrl-C. + +## Development checks + +```sh +uv run ruff format --check . +uv run ruff check . + +cd middleware +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --locked + +cd ../pi-harness +npm ci +npm run check +npm test +``` + +The automated suite is intentionally limited to five core scenarios: two Pi +harness flows plus admission transport, egress inspection, and receipt-binding +checks in the Rust service. + +The middleware scaffold, protocol, and lockfile are managed by +`openshell-middleware-manager`; do not edit its protocol by hand. + +## Supported scope and limitations + +The harness keeps Pi's native TUI, sequential tools and tool continuations, +reasoning, manual/automatic compaction, model serialization, prompt-cache +fields, and native session ownership. Allowed native messages remain unchanged. +Candidate buffers and queues are not history. + +This POC is text-only and supports one prepared OpenAI-compatible Chat +Completions model. Project instructions, skills, shell shortcuts, resume/import, +branching, renaming, live model switching, resource reload, and arbitrary +extensions are disabled. Unsupported request structures fail closed. +It does not inspect across split content blocks or decode arbitrary encodings. +Opaque provider metadata is preserved but is not claimed to be fully understood. + +Receipts bind the ordered user/tool projection, not every byte or all +assistant/system history, and do not prove that the harness itself ran. +Tool-result admission cannot reverse tool side effects. The history guarantee +is for this controlled harness, not compromised same-authority code. This is a +readable security example, not production DLP or identity validation. diff --git a/projects/pi-admission/bind-sandbox.py b/projects/pi-admission/bind-sandbox.py new file mode 100644 index 00000000..68a66c9e --- /dev/null +++ b/projects/pi-admission/bind-sandbox.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bind the service's demo credential to an operator-observed sandbox ID.""" + +import argparse +import json +import sys +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + args = parser.parse_args() + sandbox = json.load(sys.stdin) + identifier = sandbox["id"] + if not isinstance(identifier, str) or not identifier: + raise ValueError("OpenShell did not return a sandbox ID") + (args.state / "sandbox-id").write_text(identifier + "\n") + print("Admission identity bound. Run ./demo.sh launch or ./demo.sh verify.") + + +if __name__ == "__main__": + main() diff --git a/projects/pi-admission/demo.sh b/projects/pi-admission/demo.sh new file mode 100755 index 00000000..4c21bf33 --- /dev/null +++ b/projects/pi-admission/demo.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +set +x # Never trace populated credential variables. +umask 077 +example=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +state=$example/.workspaces +print_only=false +if [[ ${1:-} == --print ]]; then print_only=true; shift; fi +action=${1:-help} +# .env is trusted operator input. Print mode never executes it. +if ! $print_only && [[ -f $example/.env ]]; then + set -a + source "$example/.env" + set +a +fi +service_host=${PI_ADMISSION_HOST:-YOUR_SERVICE_HOST} +gateway=${OPENSHELL_GATEWAY:-YOUR_GATEWAY} +openshell=(openshell --gateway "$gateway") +run() { + if $print_only; then printf '%q ' "$@"; printf '\n'; else "$@"; fi +} +delete_if_present() { + local resource=$1 output status + shift + if $print_only; then run "$@"; return; fi + if output=$("$@" 2>&1); then + printf '%s\n' "$output" + else + status=$? + # Older OpenShell releases return gRPC NotFound for an absent resource. + if [[ $output == *"code: 'Some requested entity was not found'"* && + $output == *"message: \"$resource not found\""* ]]; then + printf '%s already absent; continuing cleanup.\n' "$resource" + else + printf '%s\n' "$output" >&2 + return "$status" + fi + fi +} +cd "$example" +case "$action" in + prepare) + if ! $print_only; then + : "${PI_ADMISSION_HOST:?Set the service hostname or IPv4 address in .env}" + : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" + if [[ ! -f $example/models.json ]]; then + echo 'Create models.json from models.json.example and configure your model first.' >&2 + exit 1 + fi + fi + run uv sync --frozen + if $print_only; then + printf '%q ' "${openshell[@]}" gateway list --output json + printf '| ' + run uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" --model "${PI_MODEL:-}" + else + "${openshell[@]}" gateway list --output json | uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" --model "${PI_MODEL:-}" + fi + run docker build --tag pi-admission:local "$state/image" + ;; + serve) + run cargo run --locked --manifest-path "$example/middleware/Cargo.toml" -- --config "$state/admission.json" + ;; + registration) + run cat "$state/middleware.toml" + ;; + setup) + if ! $print_only; then + : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" + : "${PI_MODEL_API_KEY:?Set PI_MODEL_API_KEY in the example .env}" + export PI_MODEL_API_KEY + PI_ADMISSION_TOKEN=$(uv run --frozen python -c 'import json,sys; print(json.load(open(sys.argv[1]))["bearer_token"])' "$state/admission.json") + export PI_ADMISSION_TOKEN + fi + run "${openshell[@]}" gateway info + for provider in model admission; do + run "${openshell[@]}" provider profile import --file "$state/$provider-provider.yaml" + variable=PI_MODEL_API_KEY + [[ $provider != admission ]] || variable=PI_ADMISSION_TOKEN + run "${openshell[@]}" provider create --name "pi-admission-$provider" --type "pi-admission-$provider" --credential "$variable" + done + run "${openshell[@]}" sandbox create --name pi-admission --from pi-admission:local --policy "$state/policy.yaml" --provider pi-admission-model --provider pi-admission-admission --detach -- /bin/sleep infinity + if $print_only; then + printf '%q ' "${openshell[@]}" sandbox get pi-admission --output json + printf '| uv run --frozen python %q --state %q\n' "$example/bind-sandbox.py" "$state" + else + "${openshell[@]}" sandbox get pi-admission --output json | uv run --frozen python "$example/bind-sandbox.py" --state "$state" + fi + ;; + launch) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${PI_ADMISSION_HOST:?Set the service host in .env}"; fi + run "${openshell[@]}" sandbox exec --tty --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/cli.js --admission "https://$service_host:5443/v1/admission" + ;; + verify) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${PI_ADMISSION_HOST:?Set the service host in .env}"; fi + run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/verify.js --admission "https://$service_host:5443/v1/admission" + ;; + cleanup) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi + delete_if_present sandbox "${openshell[@]}" sandbox delete pi-admission + for provider in model admission; do + delete_if_present provider "${openshell[@]}" provider delete "pi-admission-$provider" + delete_if_present 'provider profile' "${openshell[@]}" provider profile delete "pi-admission-$provider" + done + run uv run --frozen python -c 'import pathlib,sys; pathlib.Path(sys.argv[1]).unlink(missing_ok=True)' "$state/sandbox-id" + printf 'Sandbox and its sessions removed. Stop serve with Ctrl-C.\n' + printf 'Host configuration remains in %s; the local Docker image is retained.\n' "$state" + ;; + help) + printf 'Usage: ./demo.sh [--print] ACTION\n\n' + printf ' prepare Generate service TLS/config; build the Pi image\n' + printf ' serve Run Pi admission service (keep this terminal open)\n' + printf ' registration Show the gateway middleware TOML entry\n' + printf ' setup Create providers and sandbox; bind admission identity\n' + printf ' launch Start a new interactive Pi-powered session\n' + printf ' verify Run real deny/redact/history/compaction and bypass checks\n' + printf ' cleanup Delete sandbox, providers, and sessions\n' + printf '\n--print shows commands without executing .env, requiring secrets, or changing state.\n' + ;; + *) echo "Unknown action. Run ./demo.sh help." >&2; exit 2 ;; +esac diff --git a/projects/pi-admission/middleware/.gitignore b/projects/pi-admission/middleware/.gitignore new file mode 100644 index 00000000..6b269eab --- /dev/null +++ b/projects/pi-admission/middleware/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.env +.env.* +!.env.example +*.key +*.pem +target/ diff --git a/projects/pi-admission/middleware/.openshell-middleware-manifest.json b/projects/pi-admission/middleware/.openshell-middleware-manifest.json new file mode 100644 index 00000000..676560af --- /dev/null +++ b/projects/pi-admission/middleware/.openshell-middleware-manifest.json @@ -0,0 +1,13 @@ +{ + "openshell_version": "v0.0.116", + "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.116/proto/supervisor_middleware.proto", + "proto_sha256": "d96a963321c74c261a912dcd0b8cda690741b32b8c3d90ff3ef38dafe6681bad", + "languages": [ + "rust" + ], + "python_package": null, + "generator": { + "name": "openshell-middleware-manager", + "version": "0.0.2.dev72+4d6909f" + } +} diff --git a/projects/pi-admission/middleware/Cargo.lock b/projects/pi-admission/middleware/Cargo.lock new file mode 100644 index 00000000..00b71e51 --- /dev/null +++ b/projects/pi-admission/middleware/Cargo.lock @@ -0,0 +1,1835 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "autotools" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" +dependencies = [ + "cc", +] + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pi-admission" +version = "0.1.0" +dependencies = [ + "axum", + "axum-server", + "base64", + "bytes", + "ed25519-dalek", + "futures-core", + "jsonwebtoken", + "prost", + "prost-types", + "protobuf-src", + "rand", + "regex", + "serde", + "serde_json", + "sha2", + "subtle", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf-src" +version = "1.1.0+21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7ac8852baeb3cc6fb83b93646fb93c0ffe5d14bf138c945ceb4b9948ee0e3c1" +dependencies = [ + "autotools", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/projects/pi-admission/middleware/Cargo.toml b/projects/pi-admission/middleware/Cargo.toml new file mode 100644 index 00000000..68cd7029 --- /dev/null +++ b/projects/pi-admission/middleware/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "pi-admission" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +publish = false +license = "Apache-2.0" + +[lib] +name = "pi_admission" + +[dependencies] +axum = "0.8" +axum-server = { version = "0.8", features = ["tls-rustls"] } +base64 = "0.22" +bytes = "1" +ed25519-dalek = { version = "2", features = ["pem", "pkcs8", "rand_core"] } +futures-core = "0.3" +jsonwebtoken = "10" +prost = "0.14" +prost-types = "0.14" +rand = "0.8" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +subtle = "2" +tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal"] } +tokio-stream = { version = "0.1", features = ["net"] } +tonic = { version = "0.14", features = ["tls-ring"] } +tonic-prost = "0.14" + +[build-dependencies] +protobuf-src = "1.1.0" +tonic-prost-build = "0.14" diff --git a/projects/pi-admission/middleware/README.md b/projects/pi-admission/middleware/README.md new file mode 100644 index 00000000..a1d3d130 --- /dev/null +++ b/projects/pi-admission/middleware/README.md @@ -0,0 +1,20 @@ +# Pi admission middleware + +This OMM-managed Rust service exposes authenticated admission HTTPS on port +5443 and the OpenShell `v0.0.116` pre-credentials middleware contract over TLS +on port 50051. Run it from the parent example with `./demo.sh serve`. + +OMM owns `.openshell-middleware-manifest.json`, +`proto/supervisor_middleware.proto`, and `Cargo.lock`. Refresh those together: + +```sh +omm update --openshell-version v0.0.116 +``` + +Validate handwritten code with: + +```sh +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --locked +``` diff --git a/projects/pi-admission/middleware/build.rs b/projects/pi-admission/middleware/build.rs new file mode 100644 index 00000000..ff030cc9 --- /dev/null +++ b/projects/pi-admission/middleware/build.rs @@ -0,0 +1,16 @@ +use std::error::Error; + +fn main() -> Result<(), Box> { + // Bundle protoc so contributors do not need a separate installation. + unsafe { + std::env::set_var("PROTOC", protobuf_src::protoc()); + } + + println!("cargo:rerun-if-changed=proto/supervisor_middleware.proto"); + tonic_prost_build::configure() + .build_client(true) + .build_server(true) + .compile_protos(&["proto/supervisor_middleware.proto"], &["proto"])?; + + Ok(()) +} diff --git a/projects/pi-admission/middleware/proto/supervisor_middleware.proto b/projects/pi-admission/middleware/proto/supervisor_middleware.proto new file mode 100644 index 00000000..27fd804b --- /dev/null +++ b/projects/pi-admission/middleware/proto/supervisor_middleware.proto @@ -0,0 +1,413 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.middleware.v1; + +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; + +// SupervisorMiddleware lets an operator-run service inspect and transform +// sandbox HTTP requests and client WebSocket text messages before OpenShell +// injects credentials. +service SupervisorMiddleware { + // Describe returns the service manifest and declared bindings. + rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); + + // ValidateConfig checks service-specific configuration for one binding. + rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); + + // EvaluateHttpRequest returns an allow, deny, or mutation decision for one + // buffered HTTP request. + rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); + + // 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 +// exposes. The service is the operator-run gRPC server implementing +// SupervisorMiddleware. +message MiddlewareManifest { + // Human-readable middleware service name used only for diagnostics. This is + // not required to match an operator-owned registration name. + string name = 1; + // Release version of the middleware service implementation, used for + // diagnostics. + 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. + SupervisorMiddlewareOperation operation = 1; + // 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 logical payload or replacement this binding can process. For + // HTTP_REQUEST 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; +} + +// ValidateConfigRequest contains one policy configuration to validate. +message ValidateConfigRequest { + // Service-specific policy configuration. + google.protobuf.Struct config = 1; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 2; +} + +// ValidateConfigResponse reports whether a policy configuration is accepted. +message ValidateConfigResponse { + // True when the service accepts the configuration. + bool valid = 1; + // Human-readable validation failure reason. Empty when valid is true. + string reason = 2; +} + +// HttpRequestEvaluation contains one buffered HTTP request to evaluate. +message HttpRequestEvaluation { + // Evaluation phase selected for this request. + SupervisorMiddlewarePhase phase = 1; + // Sandbox and request identity available to the supervisor. + // The encoded context is limited to 4 KiB. + RequestContext context = 2; + // Validated service-specific policy configuration. + // The encoded configuration is limited to 64 KiB. + google.protobuf.Struct config = 3; + // Destination and HTTP request target. + // The encoded target is limited to 32 KiB. + HttpRequestTarget target = 4; + // HTTP request headers before OpenShell injects credentials, in wire + // order. Repeated header names are preserved as separate entries. Protected + // credential, routing, framing, and hop-by-hop headers are omitted. + // At most 128 lines and 64 KiB of encoded headers are included. + repeated HttpHeader headers = 5; + // Buffered request body, limited to 4 MiB. Empty for a bodyless request. + bytes body = 6; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 7; +} + +// HttpHeader is one request header line. +message HttpHeader { + // Lowercased header name. + string name = 1; + // Header value with surrounding whitespace trimmed. + string value = 2; +} + +// Supervisor operation selected for middleware evaluation. +enum SupervisorMiddlewareOperation { + SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; +} + +// 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; +} + +// 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. +message RequestContext { + // Request id used to correlate middleware and supervisor logs. + string request_id = 1; + // Sandbox id that originated the request. + 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", "https", "ws", or "wss". + string scheme = 1; + // Destination hostname selected by network policy. + string host = 2; + // Destination TCP port. + uint32 port = 3; + // HTTP request method. + string method = 4; + // Request path without the query string. + string path = 5; + // Raw request query string without the leading question mark. + string query = 6; +} + +// Process identifies a workload process and its executable ancestry. +message Process { + // Executable path for the originating process. + string binary = 1; + // Process id within the sandbox. + uint32 pid = 2; + // Executable paths for ancestor processes, nearest parent first. + repeated string ancestors = 3; +} + +// 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 current request or message and apply any returned + // mutations. + DECISION_ALLOW = 1; + // Reject the current request or message. The operation-specific result + // defines the enclosing protocol behavior. + DECISION_DENY = 2; +} + +// Finding is an audit-safe observation produced during evaluation. +message Finding { + // Stable, service-defined finding type. + string type = 1; + // Human-readable finding label that does not contain request content. + string label = 2; + // Number of matching observations represented by this finding. + uint32 count = 3; + // Service-defined confidence level. + string confidence = 4; + // Service-defined severity level. + string severity = 5; +} + +// ExistingHeaderAction controls how a header write behaves when the +// case-insensitive header name is already present. Every action writes the +// value when the header is absent. +enum ExistingHeaderAction { + EXISTING_HEADER_ACTION_UNSPECIFIED = 0; + // Add another field value without changing existing values. + EXISTING_HEADER_ACTION_APPEND = 1; + // Remove every existing value, then add the new value. + EXISTING_HEADER_ACTION_OVERWRITE = 2; + // Leave the existing values unchanged. + EXISTING_HEADER_ACTION_SKIP = 3; +} + +// WriteHeader proposes one header value and defines collision behavior. +message WriteHeader { + string name = 1; + string value = 2; + ExistingHeaderAction on_existing = 3; +} + +// RemoveHeader removes every value for a case-insensitive header name. +message RemoveHeader { + string name = 1; +} + +// HeaderMutation is one ordered request-header operation. +message HeaderMutation { + oneof operation { + WriteHeader write = 1; + RemoveHeader remove = 2; + } +} + +// HttpRequestResult contains the decision and optional request mutations. +message HttpRequestResult { + // Allow or deny decision for this request. + Decision decision = 1; + // Free-form service diagnostic. OpenShell does not relay this text into + // denied responses or security logs. Limited to 4 KiB before discarding. + string reason = 2; + // Replacement request body when has_body is true. Limited to 4 MiB. + bytes body = 3; + // True when body should replace the request body, including with an empty body. + bool has_body = 4; + // Ordered request-header mutations applied before the next middleware and + // before forwarding. Header writes are restricted to the + // "x-openshell-middleware-" namespace. Removes may target other visible + // request headers, but credential, routing, framing, and hop-by-hop headers + // are always protected. A violating result is a middleware failure handled + // according to the policy failure mode. At most 64 operations, 32 KiB of + // validated name/value data, and 64 KiB encoded are accepted. + repeated HeaderMutation header_mutations = 5; + // Audit-safe findings produced during evaluation. For operator-run services, + // OpenShell logs platform-owned fields derived from the operator-owned + // registration name rather than service-provided type, label, confidence, + // or metadata text. + // At most 32 findings of at most 4 KiB encoded each are accepted per stage. + // A policy selects at most 10 stages, so one chain retains at most 320. + repeated Finding findings = 6; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. + map metadata = 7; + // Optional stable machine-readable code for a deny decision. Codes must + // start with a lowercase ASCII letter and contain only lowercase ASCII + // letters, digits, and underscores, with a maximum length of 64 bytes. + // OpenShell may return this code to the requester, unlike free-form reason. + string reason_code = 8; +} diff --git a/projects/pi-admission/middleware/src/admission.rs b/projects/pi-admission/middleware/src/admission.rs new file mode 100644 index 00000000..0cd14a17 --- /dev/null +++ b/projects/pi-admission/middleware/src/admission.rs @@ -0,0 +1,254 @@ +use std::{fs, path::PathBuf, sync::Arc}; + +use axum::{ + Json, Router, + body::Bytes, + extract::{DefaultBodyLimit, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, + routing::post, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use subtle::ConstantTimeEq; + +use crate::{ + MAX_BODY_BYTES, + auth::GatewayAuthentication, + policy::{CandidateDecision, POLICY_ID, Projection, ProviderTarget, evaluate_candidate}, + receipt::{ReceiptAuthority, ReceiptContext}, +}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdmissionConfig { + pub listen: String, + pub tls_certificate: PathBuf, + pub tls_private_key: PathBuf, + pub gateway_public_key: PathBuf, + pub gateway_issuer: String, + pub gateway_audience: String, + pub middleware_name: String, + pub bearer_token: String, + pub sandbox_id_file: PathBuf, + pub provider_target: ProviderTarget, +} + +impl AdmissionConfig { + pub(crate) fn authentication( + &self, + ) -> Result> { + GatewayAuthentication::from_pem( + &self.gateway_public_key, + &self.gateway_issuer, + &self.gateway_audience, + ) + } +} + +#[derive(Clone)] +struct AppState { + config: Arc, + receipts: Arc, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AdmissionCall { + kind: String, + session_id: String, + submission_id: String, + body: Value, +} + +#[derive(Serialize)] +struct AdmissionResponse { + decision: &'static str, + reason_code: Option<&'static str>, + replacement: Option, + receipt: Option, + policy_identity: &'static str, +} + +pub fn admission_router(config: Arc, receipts: Arc) -> Router { + Router::new() + .route("/v1/admission", post(admit)) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES as usize)) + .with_state(AppState { config, receipts }) +} + +async fn admit(State(state): State, headers: HeaderMap, body: Bytes) -> Response { + if !authorized(&headers, &state.config.bearer_token) { + return (StatusCode::UNAUTHORIZED, "admission authentication failed").into_response(); + } + let call: AdmissionCall = match serde_json::from_slice(&body) { + Ok(call) => call, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid admission request").into_response(), + }; + if !bounded_identifier(&call.session_id) || !bounded_identifier(&call.submission_id) { + return (StatusCode::BAD_REQUEST, "invalid admission request").into_response(); + } + let sandbox_id = match fs::read_to_string(&state.config.sandbox_id_file) { + Ok(value) if bounded_identifier(value.trim()) => value.trim().to_owned(), + _ => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "admission is not provisioned", + ) + .into_response(); + } + }; + let response = match evaluate_candidate(&call.kind, call.body.clone()) { + CandidateDecision::Deny(code) => AdmissionResponse { + decision: "deny", + reason_code: Some(code), + replacement: None, + receipt: None, + policy_identity: POLICY_ID, + }, + CandidateDecision::Replace(replacement) => AdmissionResponse { + decision: "replace", + reason_code: None, + replacement: Some(replacement), + receipt: None, + policy_identity: POLICY_ID, + }, + CandidateDecision::Allow => { + let receipt = if call.kind == "provider_context" { + let projection: Projection = match serde_json::from_value( + call.body.get("entries").cloned().unwrap_or(Value::Null), + ) { + Ok(projection) => projection, + Err(_) => { + return (StatusCode::BAD_REQUEST, "invalid admission request") + .into_response(); + } + }; + let context = ReceiptContext { + middleware_name: &state.config.middleware_name, + sandbox_id: &sandbox_id, + target: &state.config.provider_target, + }; + match state.receipts.issue_header( + &projection, + context, + &call.session_id, + &call.submission_id, + ) { + Ok(receipt) => Some(receipt), + Err(_) => { + return (StatusCode::SERVICE_UNAVAILABLE, "admission is unavailable") + .into_response(); + } + } + } else { + None + }; + AdmissionResponse { + decision: "allow", + reason_code: None, + replacement: None, + receipt, + policy_identity: POLICY_ID, + } + } + }; + Json(response).into_response() +} + +fn authorized(headers: &HeaderMap, token: &str) -> bool { + let values: Vec<_> = headers.get_all(header::AUTHORIZATION).iter().collect(); + if values.len() != 1 { + return false; + } + let expected = format!("Bearer {token}"); + values[0].as_bytes().ct_eq(expected.as_bytes()).into() +} + +fn bounded_identifier(value: &str) -> bool { + !value.is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + }; + + #[tokio::test] + async fn real_http_transport_allows_replaces_denies_and_authenticates() { + let sandbox_id_file = + std::env::temp_dir().join(format!("pi-admission-sandbox-{}", std::process::id())); + fs::write(&sandbox_id_file, "sandbox-1\n").unwrap(); + let config = Arc::new(AdmissionConfig { + listen: "127.0.0.1:0".to_owned(), + tls_certificate: PathBuf::new(), + tls_private_key: PathBuf::new(), + gateway_public_key: PathBuf::new(), + gateway_issuer: "issuer".to_owned(), + gateway_audience: "audience".to_owned(), + middleware_name: "pi-admission".to_owned(), + bearer_token: "secret".to_owned(), + sandbox_id_file: sandbox_id_file.clone(), + provider_target: ProviderTarget { + scheme: "https".to_owned(), + host: "api.example.test".to_owned(), + port: 443, + method: "POST".to_owned(), + path: "/v1/chat/completions".to_owned(), + query: String::new(), + }, + }); + let receipts = Arc::new(ReceiptAuthority::new(SigningKey::from_bytes(&[3; 32]))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, admission_router(config, receipts)) + .await + .unwrap(); + }); + + let call = |text: &'static str, token: &'static str| async move { + let body = serde_json::json!({ + "kind": "user_message", + "session_id": "session", + "submission_id": "submission", + "body": { + "schema_version": "openshell.pi-message.v1", + "origin": "user", + "text": text + } + }) + .to_string(); + let request = format!( + "POST /v1/admission HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let mut stream = TcpStream::connect(address).await.unwrap(); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + String::from_utf8(response).unwrap() + }; + + assert!( + call("plain text", "wrong") + .await + .starts_with("HTTP/1.1 401") + ); + let allowed = call("plain text", "secret").await; + assert!(allowed.starts_with("HTTP/1.1 200")); + assert!(allowed.contains(r#""decision":"allow""#)); + let replaced = call("alice@example.com", "secret").await; + assert!(replaced.contains(r#""decision":"replace""#)); + assert!(replaced.contains("[EMAIL]")); + let denied = call("123-45-6789", "secret").await; + assert!(denied.contains(r#""decision":"deny""#)); + + server.abort(); + fs::remove_file(sandbox_id_file).unwrap(); + } +} diff --git a/projects/pi-admission/middleware/src/auth.rs b/projects/pi-admission/middleware/src/auth.rs new file mode 100644 index 00000000..3bf1b38e --- /dev/null +++ b/projects/pi-admission/middleware/src/auth.rs @@ -0,0 +1,65 @@ +use std::{error::Error, fs, path::Path}; + +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use serde::Deserialize; +use tonic::{Status, metadata::MetadataMap}; + +#[derive(Clone)] +pub(crate) struct GatewayAuthentication { + key: DecodingKey, + validation: Validation, +} + +#[derive(Deserialize)] +struct Claims { + caller_kind: String, + sandbox_id: Option, +} + +impl GatewayAuthentication { + pub(crate) fn from_pem( + path: &Path, + issuer: &str, + audience: &str, + ) -> Result> { + let key = DecodingKey::from_ed_pem(&fs::read(path)?)?; + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&[issuer]); + validation.set_audience(&[audience]); + validation.set_required_spec_claims(&["iss", "aud", "exp", "iat"]); + Ok(Self { key, validation }) + } + + pub(crate) fn verify( + &self, + metadata: &MetadataMap, + expected_kind: &str, + sandbox_id: Option<&str>, + ) -> Result<(), Status> { + let values: Vec<_> = metadata.get_all("authorization").iter().collect(); + if values.len() != 1 { + return Err(Status::unauthenticated("authentication required")); + } + let value = values[0] + .to_str() + .map_err(|_| Status::unauthenticated("authentication required"))?; + let token = value + .strip_prefix("Bearer ") + .filter(|token| !token.is_empty()) + .ok_or_else(|| Status::unauthenticated("authentication required"))?; + let header = + decode_header(token).map_err(|_| Status::unauthenticated("authentication failed"))?; + if header.typ.as_deref() != Some("openshell-ext+jwt") { + return Err(Status::unauthenticated("incorrect token type")); + } + let claims = decode::(token, &self.key, &self.validation) + .map_err(|_| Status::unauthenticated("authentication failed"))? + .claims; + if claims.caller_kind != expected_kind + || sandbox_id.is_some_and(|expected| claims.sandbox_id.as_deref() != Some(expected)) + { + return Err(Status::permission_denied("caller context mismatch")); + } + Ok(()) + } +} diff --git a/projects/pi-admission/middleware/src/lib.rs b/projects/pi-admission/middleware/src/lib.rs new file mode 100644 index 00000000..587cc9f3 --- /dev/null +++ b/projects/pi-admission/middleware/src/lib.rs @@ -0,0 +1,209 @@ +//! Standalone admission and egress enforcement for the Pi example. + +mod admission; +mod auth; +mod policy; +mod receipt; + +use std::{pin::Pin, sync::Arc}; + +use futures_core::Stream; +use tonic::{Request, Response, Status}; + +use auth::GatewayAuthentication; +use policy::{ProviderTarget, inspect_provider_request}; +use receipt::ReceiptContext; + +pub use admission::{AdmissionConfig, admission_router}; +pub use receipt::ReceiptAuthority; + +#[allow(clippy::large_enum_variant)] +pub mod pb { + tonic::include_proto!("openshell.middleware.v1"); +} + +use pb::supervisor_middleware_server::{SupervisorMiddleware, SupervisorMiddlewareServer}; + +pub const SERVICE_NAME: &str = "pi-admission"; +pub const SERVICE_VERSION: &str = "0.1.0"; +pub const MAX_BODY_BYTES: u64 = 4 * 1024 * 1024; +pub const MAX_MESSAGE_BYTES: usize = MAX_BODY_BYTES as usize + 1024 * 1024; +pub const RECEIPT_HEADER: &str = "x-pi-admission-receipt"; + +#[derive(Clone)] +pub struct Middleware { + authentication: Arc, + receipts: Arc, + config: Arc, +} + +impl Middleware { + pub fn from_config( + config: Arc, + receipts: Arc, + ) -> Result> { + let authentication = Arc::new(config.authentication()?); + Ok(Self::new(authentication, receipts, config)) + } + + fn new( + authentication: Arc, + receipts: Arc, + config: Arc, + ) -> Self { + Self { + authentication, + receipts, + config, + } + } + + pub fn service(self) -> SupervisorMiddlewareServer { + SupervisorMiddlewareServer::new(self) + .max_decoding_message_size(MAX_MESSAGE_BYTES) + .max_encoding_message_size(MAX_MESSAGE_BYTES) + } + + fn manifest(&self) -> pb::MiddlewareManifest { + pb::MiddlewareManifest { + name: SERVICE_NAME.to_owned(), + service_version: SERVICE_VERSION.to_owned(), + bindings: vec![pb::MiddlewareBinding { + operation: pb::SupervisorMiddlewareOperation::HttpRequest as i32, + phase: pb::SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: MAX_BODY_BYTES, + timeout: String::new(), + }], + expected_audience: self.config.gateway_audience.clone(), + } + } + + fn deny(code: &str) -> pb::HttpRequestResult { + pb::HttpRequestResult { + decision: pb::Decision::Deny as i32, + reason: "Pi admission denied the request".to_owned(), + reason_code: code.to_owned(), + ..Default::default() + } + } +} + +#[tonic::async_trait] +impl SupervisorMiddleware for Middleware { + type EvaluateWebSocketSessionStream = Pin< + Box> + Send + 'static>, + >; + + async fn describe( + &self, + request: Request<()>, + ) -> Result, Status> { + self.authentication + .verify(request.metadata(), "gateway", None)?; + Ok(Response::new(self.manifest())) + } + + async fn validate_config( + &self, + request: Request, + ) -> Result, Status> { + self.authentication + .verify(request.metadata(), "gateway", None)?; + let body = request.into_inner(); + let valid = body.middleware_name == self.config.middleware_name + && body + .config + .as_ref() + .is_none_or(|config| config.fields.is_empty()); + Ok(Response::new(pb::ValidateConfigResponse { + valid, + reason: if valid { + String::new() + } else { + "the example accepts only its empty fixed-policy configuration".to_owned() + }, + })) + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> Result, Status> { + let sandbox_id = request + .get_ref() + .context + .as_ref() + .map(|context| context.sandbox_id.as_str()) + .filter(|value| !value.is_empty()); + self.authentication + .verify(request.metadata(), "supervisor", sandbox_id)?; + let request = request.into_inner(); + if request.phase != pb::SupervisorMiddlewarePhase::PreCredentials as i32 { + return Ok(Response::new(Self::deny("unsupported_phase"))); + } + if request.middleware_name != self.config.middleware_name { + return Ok(Response::new(Self::deny("middleware_context_mismatch"))); + } + let Some(context) = request.context else { + return Ok(Response::new(Self::deny("request_context_missing"))); + }; + let Some(target) = request.target else { + return Ok(Response::new(Self::deny("provider_shape_unsupported"))); + }; + let receipts: Vec<_> = request + .headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case(RECEIPT_HEADER)) + .collect(); + if receipts.is_empty() { + return Ok(Response::new(Self::deny("receipt_missing"))); + } + if receipts.len() != 1 { + return Ok(Response::new(Self::deny("receipt_malformed"))); + } + let provider_target = ProviderTarget { + scheme: target.scheme, + host: target.host, + port: target.port, + method: target.method, + path: target.path, + query: target.query, + }; + if provider_target != self.config.provider_target { + return Ok(Response::new(Self::deny("receipt_context_mismatch"))); + } + let projection = match inspect_provider_request(&request.body, &request.headers) { + Ok(projection) => projection, + Err(code) => return Ok(Response::new(Self::deny(code))), + }; + let receipt_context = ReceiptContext { + middleware_name: &self.config.middleware_name, + sandbox_id: &context.sandbox_id, + target: &provider_target, + }; + if let Err(code) = + self.receipts + .verify_header(&receipts[0].value, &projection, receipt_context) + { + return Ok(Response::new(Self::deny(code))); + } + Ok(Response::new(pb::HttpRequestResult { + decision: pb::Decision::Allow as i32, + header_mutations: vec![pb::HeaderMutation { + operation: Some(pb::header_mutation::Operation::Remove(pb::RemoveHeader { + name: RECEIPT_HEADER.to_owned(), + })), + }], + ..Default::default() + })) + } + + async fn evaluate_web_socket_session( + &self, + _request: Request>, + ) -> Result, Status> { + Err(Status::unimplemented( + "Pi admission supports HTTP requests only", + )) + } +} diff --git a/projects/pi-admission/middleware/src/main.rs b/projects/pi-admission/middleware/src/main.rs new file mode 100644 index 00000000..6531345f --- /dev/null +++ b/projects/pi-admission/middleware/src/main.rs @@ -0,0 +1,53 @@ +use std::{env, error::Error, fs, net::SocketAddr, path::PathBuf, sync::Arc}; + +use ed25519_dalek::SigningKey; +use pi_admission::{AdmissionConfig, Middleware, ReceiptAuthority, admission_router}; +use rand::rngs::OsRng; +use tonic::transport::{Identity, Server, ServerTlsConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let path = config_path()?; + let config: AdmissionConfig = serde_json::from_slice(&fs::read(path)?)?; + let config = Arc::new(config); + let receipts = Arc::new(ReceiptAuthority::new(SigningKey::generate(&mut OsRng))); + let middleware = Middleware::from_config(config.clone(), receipts.clone())?; + let certificate = fs::read(&config.tls_certificate)?; + let private_key = fs::read(&config.tls_private_key)?; + let grpc_address: SocketAddr = "0.0.0.0:50051".parse()?; + let admission_address: SocketAddr = config.listen.parse()?; + let tls = + axum_server::tls_rustls::RustlsConfig::from_pem(certificate.clone(), private_key.clone()) + .await?; + + println!("serving Pi admission HTTPS on {admission_address} and gRPC on {grpc_address}"); + let grpc = Server::builder() + .tls_config(ServerTlsConfig::new().identity(Identity::from_pem(certificate, private_key)))? + .add_service(middleware.service()) + .serve(grpc_address); + let http = axum_server::bind_rustls(admission_address, tls) + .serve(admission_router(config, receipts).into_make_service()); + tokio::try_join!( + async { + grpc.await + .map_err(|error| -> Box { Box::new(error) }) + }, + async { + http.await + .map_err(|error| -> Box { Box::new(error) }) + }, + )?; + Ok(()) +} + +fn config_path() -> Result> { + let mut arguments = env::args_os().skip(1); + if arguments.next().as_deref() != Some("--config".as_ref()) { + return Err("usage: pi-admission --config PATH".into()); + } + let path = arguments.next().ok_or("missing configuration path")?; + if arguments.next().is_some() { + return Err("unexpected argument".into()); + } + Ok(path.into()) +} diff --git a/projects/pi-admission/middleware/src/policy.rs b/projects/pi-admission/middleware/src/policy.rs new file mode 100644 index 00000000..6164c596 --- /dev/null +++ b/projects/pi-admission/middleware/src/policy.rs @@ -0,0 +1,362 @@ +use std::{collections::BTreeSet, sync::LazyLock}; + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::pb; + +pub(crate) const POLICY_ID: &str = "pi-admission-fixed-regex.v1"; + +static EMAIL: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\b[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@example\.com\b").unwrap()); +static SSN: LazyLock = + LazyLock::new(|| Regex::new(r"\b[0-9]{3}-[0-9]{2}-[0-9]{4}\b").unwrap()); + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderTarget { + pub scheme: String, + pub host: String, + pub port: u32, + pub method: String, + pub path: String, + pub query: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(tag = "role", deny_unknown_fields)] +pub(crate) enum ContextEntry { + #[serde(rename = "user")] + User { text: String }, + #[serde(rename = "tool")] + Tool { tool_call_id: String, text: String }, +} + +pub(crate) type Projection = Vec; + +pub(crate) enum CandidateDecision { + Allow, + Replace(Value), + Deny(&'static str), +} + +pub(crate) fn evaluate_candidate(kind: &str, mut body: Value) -> CandidateDecision { + if contains_match(&body, &SSN) { + return CandidateDecision::Deny("ssn_detected"); + } + let result = match kind { + "user_message" => edit_message(&mut body, "user"), + "system_context" => edit_message(&mut body, "system"), + "compaction_summary" => edit_message(&mut body, "compaction_summary"), + "tool_result" => edit_tool_result(&mut body), + "assistant_message" => edit_assistant(&mut body), + "provider_context" => validate_provider_context(&body), + _ => Err("admission_contract_invalid"), + }; + match result { + Err(code) => CandidateDecision::Deny(code), + Ok(false) => CandidateDecision::Allow, + Ok(true) => CandidateDecision::Replace(body), + } +} + +pub(crate) fn inspect_provider_request( + body: &[u8], + headers: &[pb::HttpHeader], +) -> Result { + let content_types: Vec<_> = headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("content-type")) + .map(|header| header.value.trim().to_ascii_lowercase()) + .collect(); + if content_types != ["application/json"] + || headers + .iter() + .any(|header| header.name.eq_ignore_ascii_case("content-encoding")) + { + return Err("provider_shape_unsupported"); + } + let value: Value = serde_json::from_slice(body).map_err(|_| "provider_shape_unsupported")?; + if contains_match(&value, &SSN) || contains_match(&value, &EMAIL) { + return Err("entity_detected_at_egress"); + } + let object = value.as_object().ok_or("provider_shape_unsupported")?; + exact_keys( + object, + &[ + "model", + "messages", + "tools", + "tool_choice", + "temperature", + "top_p", + "max_completion_tokens", + "max_tokens", + "stream", + "stream_options", + "store", + "prompt_cache_key", + "prompt_cache_retention", + "reasoning_effort", + "reasoning", + "enable_thinking", + ], + )?; + if object.get("model").and_then(Value::as_str).is_none() + || object.get("stream") != Some(&Value::Bool(true)) + || (object.contains_key("max_tokens") == object.contains_key("max_completion_tokens")) + { + return Err("provider_shape_unsupported"); + } + let messages = object + .get("messages") + .and_then(Value::as_array) + .ok_or("provider_shape_unsupported")?; + let mut projection = Vec::new(); + for message in messages { + let message = message.as_object().ok_or("provider_shape_unsupported")?; + exact_keys( + message, + &[ + "role", + "content", + "name", + "tool_call_id", + "tool_calls", + "reasoning_content", + "reasoning", + "reasoning_text", + "reasoning_details", + ], + )?; + let role = message + .get("role") + .and_then(Value::as_str) + .ok_or("provider_shape_unsupported")?; + let content = message.get("content").ok_or("provider_shape_unsupported")?; + let text = provider_content(content)?; + match role { + "user" => { + if let Some(text) = text { + projection.push(ContextEntry::User { text }); + } + } + "tool" => { + let id = message + .get("tool_call_id") + .and_then(Value::as_str) + .ok_or("provider_shape_unsupported")?; + let text = text.ok_or("provider_shape_unsupported")?; + projection.push(ContextEntry::Tool { + tool_call_id: id.split('|').next().unwrap_or(id).to_owned(), + text, + }); + } + "system" | "developer" | "assistant" => {} + _ => return Err("provider_shape_unsupported"), + } + } + if projection.is_empty() { + return Err("provider_shape_unsupported"); + } + Ok(projection) +} + +fn edit_message(body: &mut Value, origin: &str) -> Result { + let object = shape(body, &["schema_version", "origin", "text"])?; + if object.get("schema_version").and_then(Value::as_str) != Some("openshell.pi-message.v1") + || object.get("origin").and_then(Value::as_str) != Some(origin) + { + return Err("admission_contract_invalid"); + } + replace_field(object, "text") +} + +fn edit_tool_result(body: &mut Value) -> Result { + let object = shape( + body, + &[ + "schema_version", + "tool_call_id", + "tool_name", + "content", + "is_error", + ], + )?; + if object.get("schema_version").and_then(Value::as_str) != Some("openshell.pi-tool-result.v1") + || object.get("tool_call_id").and_then(Value::as_str).is_none() + || object.get("tool_name").and_then(Value::as_str).is_none() + || object.get("is_error").and_then(Value::as_bool).is_none() + { + return Err("admission_contract_invalid"); + } + let blocks = object + .get_mut("content") + .and_then(Value::as_array_mut) + .ok_or("admission_contract_invalid")?; + let mut changed = false; + for block in blocks { + let block = shape(block, &["type", "text"])?; + if block.get("type").and_then(Value::as_str) != Some("text") { + return Err("admission_contract_invalid"); + } + changed |= replace_field(block, "text")?; + } + Ok(changed) +} + +fn edit_assistant(body: &mut Value) -> Result { + let object = shape(body, &["schema_version", "text", "tool_calls", "thinking"])?; + if object.get("schema_version").and_then(Value::as_str) + != Some("openshell.pi-assistant-message.v1") + { + return Err("admission_contract_invalid"); + } + if contains_match( + object + .get("tool_calls") + .ok_or("admission_contract_invalid")?, + &EMAIL, + ) { + return Err("immutable_content_detected"); + } + let mut changed = replace_field(object, "text")?; + let thinking = object + .get_mut("thinking") + .and_then(Value::as_array_mut) + .ok_or("admission_contract_invalid")?; + for block in thinking { + let block = shape(block, &["text", "signature"])?; + let redactable = block + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| EMAIL.is_match(text)); + let signature = block.get("signature").ok_or("admission_contract_invalid")?; + let editable = signature.is_null() + || matches!( + signature.as_str(), + Some("reasoning" | "reasoning_content" | "reasoning_text") + ); + if redactable && !editable { + return Err("immutable_content_detected"); + } + if editable { + changed |= replace_field(block, "text")?; + } + } + Ok(changed) +} + +fn validate_provider_context(body: &Value) -> Result { + let object = body.as_object().ok_or("admission_contract_invalid")?; + exact_keys(object, &["schema_version", "entries"])?; + if object.get("schema_version").and_then(Value::as_str) + != Some("openshell.pi-provider-context.v1") + { + return Err("admission_contract_invalid"); + } + let entries: Projection = serde_json::from_value( + object + .get("entries") + .cloned() + .ok_or("admission_contract_invalid")?, + ) + .map_err(|_| "admission_contract_invalid")?; + if entries.is_empty() { + return Err("admission_contract_invalid"); + } + if contains_match(body, &EMAIL) { + return Err("email_detected_at_receipt"); + } + Ok(false) +} + +fn provider_content(value: &Value) -> Result, &'static str> { + if value.is_null() { + return Ok(None); + } + if let Some(text) = value.as_str() { + return Ok(Some(text.to_owned())); + } + let blocks = value.as_array().ok_or("provider_shape_unsupported")?; + let mut text = Vec::new(); + for block in blocks { + let block = block.as_object().ok_or("provider_shape_unsupported")?; + exact_keys(block, &["type", "text", "cache_control"])?; + if block.get("type").and_then(Value::as_str) != Some("text") { + return Err("provider_shape_unsupported"); + } + text.push( + block + .get("text") + .and_then(Value::as_str) + .ok_or("provider_shape_unsupported")?, + ); + } + Ok(Some(text.join("\n"))) +} + +fn replace_field(object: &mut Map, key: &str) -> Result { + let value = object + .get_mut(key) + .and_then(|value| value.as_str()) + .ok_or("admission_contract_invalid")?; + let replacement = EMAIL.replace_all(value, "[EMAIL]"); + if replacement == value { + return Ok(false); + } + *object.get_mut(key).unwrap() = Value::String(replacement.into_owned()); + Ok(true) +} + +fn shape<'a>( + value: &'a mut Value, + keys: &[&str], +) -> Result<&'a mut Map, &'static str> { + let object = value.as_object_mut().ok_or("admission_contract_invalid")?; + exact_keys(object, keys)?; + Ok(object) +} + +fn exact_keys(object: &Map, allowed: &[&str]) -> Result<(), &'static str> { + let allowed: BTreeSet<_> = allowed.iter().copied().collect(); + if object.keys().any(|key| !allowed.contains(key.as_str())) { + return Err("provider_shape_unsupported"); + } + Ok(()) +} + +fn contains_match(value: &Value, pattern: &Regex) -> bool { + match value { + Value::String(text) => pattern.is_match(text), + Value::Array(values) => values.iter().any(|value| contains_match(value, pattern)), + Value::Object(values) => values.values().any(|value| contains_match(value, pattern)), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn egress_allows_approved_content_and_rejects_decoded_entities() { + let headers = [pb::HttpHeader { + name: "content-type".to_owned(), + value: "application/json".to_owned(), + }]; + let allowed = br#"{"model":"demo","messages":[{"role":"user","content":"[EMAIL]"}],"max_tokens":10,"stream":true}"#; + assert_eq!( + inspect_provider_request(allowed, &headers).unwrap(), + vec![ContextEntry::User { + text: "[EMAIL]".to_owned() + }] + ); + let escaped = br#"{"model":"demo","messages":[{"role":"user","content":"alice\u0040example.com"}],"max_tokens":10,"stream":true}"#; + assert_eq!( + inspect_provider_request(escaped, &headers), + Err("entity_detected_at_egress") + ); + } +} diff --git a/projects/pi-admission/middleware/src/receipt.rs b/projects/pi-admission/middleware/src/receipt.rs new file mode 100644 index 00000000..efee953b --- /dev/null +++ b/projects/pi-admission/middleware/src/receipt.rs @@ -0,0 +1,302 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::{Engine, engine::general_purpose}; +use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; +use rand::{RngCore, rngs::OsRng}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::policy::{POLICY_ID, Projection, ProviderTarget}; + +const LIFETIME_SECONDS: u64 = 300; +const CLOCK_SKEW_SECONDS: u64 = 5; +const MAX_RECEIPT_BYTES: usize = 8 * 1024; + +#[derive(Clone)] +pub struct ReceiptAuthority { + signing_key: SigningKey, + verifying_key: VerifyingKey, + key_id: String, +} + +pub(crate) struct ReceiptContext<'a> { + pub middleware_name: &'a str, + pub sandbox_id: &'a str, + pub target: &'a ProviderTarget, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Claims { + receipt_version: String, + canonicalization_version: String, + harness: String, + harness_version: String, + harness_schema: String, + hook: String, + middleware_binding: String, + policy_identity: String, + sandbox_id: String, + session_id: String, + submission_id: String, + receipt_id: String, + provider_adapter_schema: String, + host: String, + port: u32, + subject_kind: String, + subject_hash: String, + entry_count: usize, + issued_at: u64, + expires_at: u64, + key_id: String, +} + +impl ReceiptAuthority { + pub fn new(signing_key: SigningKey) -> Self { + let verifying_key = signing_key.verifying_key(); + let key_id = hex(&Sha256::digest(verifying_key.as_bytes()))[..16].to_owned(); + Self { + signing_key, + verifying_key, + key_id, + } + } + + pub(crate) fn issue_header( + &self, + projection: &Projection, + context: ReceiptContext<'_>, + session_id: &str, + submission_id: &str, + ) -> Result { + self.issue_header_at(projection, context, session_id, submission_id, now()?) + } + + fn issue_header_at( + &self, + projection: &Projection, + context: ReceiptContext<'_>, + session_id: &str, + submission_id: &str, + issued_at: u64, + ) -> Result { + let mut identifier = [0_u8; 16]; + OsRng.fill_bytes(&mut identifier); + let claims = Claims { + receipt_version: "pi-admission-receipt.v1".to_owned(), + canonicalization_version: "canonical-json.v1".to_owned(), + harness: "pi".to_owned(), + harness_version: "sdk-v1".to_owned(), + harness_schema: "openshell.pi-provider-context.v1".to_owned(), + hook: "provider_context".to_owned(), + middleware_binding: context.middleware_name.to_owned(), + policy_identity: POLICY_ID.to_owned(), + sandbox_id: context.sandbox_id.to_owned(), + session_id: session_id.to_owned(), + submission_id: submission_id.to_owned(), + receipt_id: hex(&identifier), + provider_adapter_schema: "openai.request.v1".to_owned(), + host: context.target.host.clone(), + port: context.target.port, + subject_kind: "context".to_owned(), + subject_hash: subject(projection)?, + entry_count: projection.len(), + issued_at, + expires_at: issued_at + LIFETIME_SECONDS, + key_id: self.key_id.clone(), + }; + let payload = serde_json::to_vec(&claims).map_err(|_| "receipt_issuance_failed")?; + let signature = self.signing_key.sign(&payload); + let token = format!( + "pr1.{}.{}", + general_purpose::URL_SAFE_NO_PAD.encode(payload), + general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes()) + ); + Ok(general_purpose::URL_SAFE.encode(token.as_bytes())) + } + + pub(crate) fn verify_header( + &self, + header: &str, + projection: &Projection, + context: ReceiptContext<'_>, + ) -> Result<(), &'static str> { + self.verify_header_at(header, projection, context, now()?) + } + + fn verify_header_at( + &self, + header: &str, + projection: &Projection, + context: ReceiptContext<'_>, + current: u64, + ) -> Result<(), &'static str> { + if header.len() > MAX_RECEIPT_BYTES * 4 / 3 + 4 { + return Err("receipt_malformed"); + } + let token = general_purpose::URL_SAFE + .decode(header) + .map_err(|_| "receipt_malformed")?; + if token.len() > MAX_RECEIPT_BYTES { + return Err("receipt_malformed"); + } + let token = std::str::from_utf8(&token).map_err(|_| "receipt_malformed")?; + let mut parts = token.split('.'); + if parts.next() != Some("pr1") { + return Err("receipt_malformed"); + } + let payload = general_purpose::URL_SAFE_NO_PAD + .decode(parts.next().ok_or("receipt_malformed")?) + .map_err(|_| "receipt_malformed")?; + let signature = general_purpose::URL_SAFE_NO_PAD + .decode(parts.next().ok_or("receipt_malformed")?) + .map_err(|_| "receipt_malformed")?; + if parts.next().is_some() { + return Err("receipt_malformed"); + } + let signature = + ed25519_dalek::Signature::from_slice(&signature).map_err(|_| "receipt_malformed")?; + self.verifying_key + .verify(&payload, &signature) + .map_err(|_| "receipt_signature_invalid")?; + let claims: Claims = serde_json::from_slice(&payload).map_err(|_| "receipt_malformed")?; + if serde_json::to_vec(&claims).map_err(|_| "receipt_malformed")? != payload { + return Err("receipt_malformed"); + } + if claims.issued_at > current + CLOCK_SKEW_SECONDS { + return Err("receipt_not_yet_valid"); + } + if claims.expires_at <= current || claims.expires_at <= claims.issued_at { + return Err("receipt_expired"); + } + if claims.receipt_version != "pi-admission-receipt.v1" + || claims.canonicalization_version != "canonical-json.v1" + || claims.harness != "pi" + || claims.harness_version != "sdk-v1" + || claims.harness_schema != "openshell.pi-provider-context.v1" + || claims.hook != "provider_context" + || claims.middleware_binding != context.middleware_name + || claims.policy_identity != POLICY_ID + || claims.sandbox_id != context.sandbox_id + || claims.provider_adapter_schema != "openai.request.v1" + || claims.host != context.target.host + || claims.port != context.target.port + || claims.subject_kind != "context" + || claims.key_id != self.key_id + { + return Err("receipt_context_mismatch"); + } + if claims.entry_count != projection.len() || claims.subject_hash != subject(projection)? { + return Err("receipt_content_mismatch"); + } + Ok(()) + } +} + +fn subject(projection: &Projection) -> Result { + let bytes = serde_json::to_vec(projection).map_err(|_| "receipt_content_invalid")?; + Ok(hex(&Sha256::digest(bytes))) +} + +fn now() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| "clock_invalid") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::ContextEntry; + + fn target() -> ProviderTarget { + ProviderTarget { + scheme: "https".to_owned(), + host: "api.example.test".to_owned(), + port: 443, + method: "POST".to_owned(), + path: "/v1/chat/completions".to_owned(), + query: String::new(), + } + } + + #[test] + fn receipt_binds_content_destination_and_sandbox() { + let authority = ReceiptAuthority::new(SigningKey::from_bytes(&[7; 32])); + let target = target(); + let projection = vec![ContextEntry::User { + text: "approved".to_owned(), + }]; + let context = ReceiptContext { + middleware_name: "pi-admission", + sandbox_id: "sandbox-1", + target: &target, + }; + let receipt = authority + .issue_header(&projection, context, "session", "submission") + .unwrap(); + let context = ReceiptContext { + middleware_name: "pi-admission", + sandbox_id: "sandbox-1", + target: &target, + }; + assert!( + authority + .verify_header(&receipt, &projection, context) + .is_ok() + ); + let mut invalid = general_purpose::URL_SAFE.decode(&receipt).unwrap(); + let start = invalid.iter().rposition(|byte| *byte == b'.').unwrap() + 1; + invalid[start] = if invalid[start] == b'A' { b'B' } else { b'A' }; + let invalid = general_purpose::URL_SAFE.encode(invalid); + let context = ReceiptContext { + middleware_name: "pi-admission", + sandbox_id: "sandbox-1", + target: &target, + }; + assert_eq!( + authority.verify_header(&invalid, &projection, context), + Err("receipt_signature_invalid") + ); + let changed = vec![ContextEntry::User { + text: "changed".to_owned(), + }]; + let context = ReceiptContext { + middleware_name: "pi-admission", + sandbox_id: "sandbox-1", + target: &target, + }; + assert_eq!( + authority.verify_header(&receipt, &changed, context), + Err("receipt_content_mismatch") + ); + + let wrong_target = ProviderTarget { + host: "other.example.test".to_owned(), + ..target.clone() + }; + let context = ReceiptContext { + middleware_name: "pi-admission", + sandbox_id: "sandbox-1", + target: &wrong_target, + }; + assert_eq!( + authority.verify_header(&receipt, &projection, context), + Err("receipt_context_mismatch") + ); + let context = ReceiptContext { + middleware_name: "pi-admission", + sandbox_id: "sandbox-2", + target: &target, + }; + assert_eq!( + authority.verify_header(&receipt, &projection, context), + Err("receipt_context_mismatch") + ); + } +} diff --git a/projects/pi-admission/models.json.example b/projects/pi-admission/models.json.example new file mode 100644 index 00000000..4c0e6335 --- /dev/null +++ b/projects/pi-admission/models.json.example @@ -0,0 +1,40 @@ +{ + "providers": { + "openrouter": { + "baseUrl": "https://openrouter.ai/api/v1", + "api": "openai-completions", + "compat": { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsReasoningEffort": true + }, + "models": [ + { + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash (OpenRouter)", + "reasoning": true, + "thinkingLevelMap": { + "off": null, + "minimal": null, + "low": "low", + "medium": null, + "high": "high", + "xhigh": null, + "max": "max" + }, + "input": [ + "text" + ], + "contextWindow": 1048576, + "maxTokens": 32768, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + } + } + ] + } + } +} diff --git a/projects/pi-admission/pi-harness/package-lock.json b/projects/pi-admission/pi-harness/package-lock.json new file mode 100644 index 00000000..a65285d4 --- /dev/null +++ b/projects/pi-admission/pi-harness/package-lock.json @@ -0,0 +1,2219 @@ +{ + "name": "pi-admission-harness", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-admission-harness", + "license": "Apache-2.0", + "dependencies": { + "@earendil-works/pi-agent-core": "0.85.1", + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "undici": "8.9.0" + }, + "devDependencies": { + "@types/node": "22.19.19", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/chord": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.85.1.tgz", + "integrity": "sha512-VDlkEC3dhCzQ5fcyH1OhG19dq+6jCn+rqc/iXFivwDYGR5anwo2RCiXij9PpHhqNR5GuhhE+Er69Zi1Sn4eY6w==", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.85.1.tgz", + "integrity": "sha512-hIXIP3eAWueAYiAl8aMvWCvvZ8Q5gT3Dip5bE5uJyIGh4+YlWRjtMLI4BaeoXoSs93zndjue61u1B/vhefLnuA==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-telemetry": "^0.85.1", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz", + "integrity": "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.123.0", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.85.1", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz", + "integrity": "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-agent-core": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-tui": "^0.85.1", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz", + "integrity": "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.85.1.tgz", + "integrity": "sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/projects/pi-admission/pi-harness/package.json b/projects/pi-admission/pi-harness/package.json new file mode 100644 index 00000000..a074ca28 --- /dev/null +++ b/projects/pi-admission/pi-harness/package.json @@ -0,0 +1,25 @@ +{ + "name": "pi-admission-harness", + "private": true, + "license": "Apache-2.0", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "build": "tsc", + "check": "tsc --noEmit", + "test": "node --test dist/test/e2e.test.js", + "start": "node dist/src/cli.js" + }, + "dependencies": { + "@earendil-works/pi-agent-core": "0.85.1", + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "undici": "8.9.0" + }, + "devDependencies": { + "@types/node": "22.19.19", + "typescript": "5.9.3" + } +} diff --git a/projects/pi-admission/pi-harness/src/admission.ts b/projects/pi-admission/pi-harness/src/admission.ts new file mode 100644 index 00000000..0e2328b5 --- /dev/null +++ b/projects/pi-admission/pi-harness/src/admission.ts @@ -0,0 +1,297 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import type { + Context, + Message, + TextContent, +} from "@earendil-works/pi-ai"; + +export const RECEIPT_HEADER = "x-pi-admission-receipt"; +export const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; + +export type TextOrigin = "user" | "system" | "compaction_summary"; +export type AdmissionKind = + | "user_message" + | "system_context" + | "compaction_summary" + | "assistant_message" + | "tool_result" + | "provider_context"; +export type AdmissionResponse = { + decision: "allow" | "replace" | "deny"; + replacement: Record | null; + receipt: string | null; +}; +export type Evaluate = ( + kind: AdmissionKind, + body: Record, + signal?: AbortSignal, +) => Promise; + +export class AdmissionError extends Error { + constructor( + readonly kind: "denied" | "unavailable" | "unsupported" | "invalid", + ) { + super( + { + denied: + "Admission denied this content; the candidate was not added to history.", + unavailable: + "Admission is unavailable; no unchecked content will be added.", + unsupported: + "This content is outside the example’s supported Chat Completions format.", + invalid: + "Admission returned an inconsistent result; the operation was stopped.", + }[kind], + ); + } +} + +export function createHttpEvaluator( + url: string, + credential: string, + sessionId: string, +): Evaluate { + if (new URL(url).protocol !== "https:") + throw new Error("Admission requires HTTPS."); + return async (kind, body, signal) => { + const encoded = JSON.stringify({ + kind, + body, + session_id: sessionId, + submission_id: randomUUID(), + }); + if (Buffer.byteLength(encoded) > MAX_ADMISSION_BYTES) + throw new AdmissionError("unsupported"); + try { + const response = await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${credential}`, + "content-type": "application/json", + }, + body: encoded, + signal: AbortSignal.any([ + AbortSignal.timeout(30_000), + ...(signal ? [signal] : []), + ]), + }); + if (!response.ok) throw new AdmissionError("unavailable"); + const encodedResult = await response.text(); + if (Buffer.byteLength(encodedResult) > MAX_ADMISSION_BYTES + 16_384) + throw new AdmissionError("invalid"); + const result: unknown = JSON.parse(encodedResult); + if ( + !isRecord(result) || + !["allow", "replace", "deny"].includes(String(result.decision)) + ) + throw new AdmissionError("invalid"); + if (result.decision === "deny") + return { decision: "deny", replacement: null, receipt: null }; + if (result.decision === "allow" && result.replacement !== null) + throw new AdmissionError("invalid"); + if (result.decision === "replace" && !isRecord(result.replacement)) + throw new AdmissionError("invalid"); + if (kind === "provider_context") { + if ( + typeof result.receipt !== "string" || + !/^[A-Za-z0-9_-]+={0,2}$/.test(result.receipt) || + result.receipt.length > 11_000 + ) + throw new AdmissionError("invalid"); + } else if (result.receipt !== null) throw new AdmissionError("invalid"); + return { + decision: result.decision as "allow" | "replace", + replacement: result.replacement as Record | null, + receipt: result.receipt as string | null, + }; + } catch (error) { + if (error instanceof AdmissionError) throw error; + throw new AdmissionError("unavailable"); + } + }; +} + +export class Admission { + constructor(private readonly evaluate: Evaluate) {} + + async text( + origin: TextOrigin, + text: string, + signal?: AbortSignal, + ): Promise { + const kind = { + user: "user_message", + system: "system_context", + compaction_summary: "compaction_summary", + } as const; + const envelope = { + schema_version: "openshell.pi-message.v1", + origin, + text, + }; + const admitted = await this.apply(kind[origin], envelope, signal); + if ( + admitted.origin !== origin || + admitted.schema_version !== envelope.schema_version || + typeof admitted.text !== "string" + ) + throw new AdmissionError("invalid"); + return admitted.text; + } + + async message(message: Message, signal?: AbortSignal): Promise { + if (message.role === "user") { + const original = textOnly(message.content); + const approved = await this.text("user", original, signal); + if (approved === original) return message; + if (typeof message.content === "string") + return { ...message, content: approved }; + if (message.content.length !== 1 || message.content[0].type !== "text" || + message.content[0].textSignature) + throw new AdmissionError("invalid"); + return { ...message, content: [{ ...message.content[0], text: approved }] }; + } + if (message.role === "assistant") { + if (message.content.some((block) => + block.type !== "text" && block.type !== "toolCall" && block.type !== "thinking")) + throw new AdmissionError("unsupported"); + const texts = message.content.filter((block) => block.type === "text"); + const thinking = message.content.filter((block) => block.type === "thinking"); + const calls = message.content.filter((block) => block.type === "toolCall").map((call) => ({ + id: call.id, name: call.name, arguments: call.arguments, + thought_signature: call.thoughtSignature ?? null, + })); + const envelope = { + schema_version: "openshell.pi-assistant-message.v1", + text: texts.map((block) => block.text).join("\n"), + tool_calls: calls, + thinking: thinking.map((block) => ({ + text: block.thinking, signature: block.thinkingSignature ?? null, + })), + }; + const admitted = await this.apply("assistant_message", envelope, signal); + // Preserve the complete native message on allow, including block order, + // signatures, usage and provider metadata. + if (admitted === envelope) return message; + if (typeof admitted.text !== "string" || + !isDeepStrictEqual(admitted.tool_calls, calls) || + !Array.isArray(admitted.thinking) || admitted.thinking.length !== thinking.length) + throw new AdmissionError("invalid"); + const changedText = admitted.text !== envelope.text; + // A joined text projection cannot safely identify edits across multiple blocks. + if (changedText && (texts.length !== 1 || texts[0].textSignature)) + throw new AdmissionError("invalid"); + const replacements = admitted.thinking.map((value: unknown, index: number) => { + const original = envelope.thinking[index]; + if (!isRecord(value) || typeof value.text !== "string" || + value.signature !== original.signature || + (original.signature !== null && + !["reasoning", "reasoning_content", "reasoning_text"].includes(original.signature) && + value.text !== original.text)) + throw new AdmissionError("invalid"); + return value.text; + }); + let index = 0; + return { + ...message, + content: message.content.map((block) => { + if (block.type === "text" && changedText) + return { ...block, text: admitted.text as string }; + if (block.type === "thinking") + return { ...block, thinking: replacements[index++] }; + return block; + }), + }; + } + // Keep text block boundaries and metadata; images remain outside this POC. + textOnly(message.content); + const envelope = { + schema_version: "openshell.pi-tool-result.v1", + tool_call_id: message.toolCallId, + tool_name: message.toolName, + content: message.content.map((block) => ({ type: "text", text: (block as TextContent).text })), + is_error: message.isError, + }; + const admitted = await this.apply("tool_result", envelope, signal); + if (admitted === envelope) return message; + if (admitted.tool_call_id !== message.toolCallId || + admitted.tool_name !== message.toolName || admitted.is_error !== message.isError || + !Array.isArray(admitted.content) || admitted.content.length !== message.content.length) + throw new AdmissionError("invalid"); + const content = admitted.content.map((value: unknown, index: number): TextContent => { + const original = message.content[index] as TextContent; + if (!isRecord(value) || value.type !== "text" || typeof value.text !== "string" || + (original.textSignature && value.text !== original.text)) + throw new AdmissionError("invalid"); + return { ...original, text: value.text }; + }); + return { ...message, content }; + } + + async receipt(context: Context, signal?: AbortSignal): Promise { + const entries = context.messages.flatMap((message) => { + if (message.role === "user") + return [{ role: "user", text: textOnly(message.content) }]; + if (message.role === "toolResult") + return [ + { + role: "tool", + tool_call_id: message.toolCallId.split("|", 1)[0], + text: textOnly(message.content) || "(no tool output)", + }, + ]; + return []; + }); + const result = await this.evaluate( + "provider_context", + { schema_version: "openshell.pi-provider-context.v1", entries }, + signal, + ); + if (result.decision === "deny") throw new AdmissionError("denied"); + // A send-only replacement would leave saved history inconsistent. Fix the + // earlier admission boundary instead of silently diverging at egress. + if (result.decision !== "allow" || !result.receipt) + throw new AdmissionError("invalid"); + return result.receipt; + } + + private async apply( + kind: AdmissionKind, + body: Record, + signal?: AbortSignal, + ): Promise> { + const result = await this.evaluate(kind, body, signal); + if (signal?.aborted) throw new AdmissionError("unavailable"); + if (result.decision === "deny") throw new AdmissionError("denied"); + if (result.decision === "replace") { + if ( + !result.replacement || + result.replacement.schema_version !== body.schema_version + ) + throw new AdmissionError("invalid"); + return result.replacement; + } + return body; + } +} + +export function textOnly( + content: string | readonly { type: string; text?: string }[], +): string { + if (typeof content === "string") return content; + if ( + content.some( + (block) => block.type !== "text" || typeof block.text !== "string", + ) + ) + throw new AdmissionError("unsupported"); + return content.map((block) => block.text).join("\n"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/projects/pi-admission/pi-harness/src/agent.ts b/projects/pi-admission/pi-harness/src/agent.ts new file mode 100644 index 00000000..f037c2e2 --- /dev/null +++ b/projects/pi-admission/pi-harness/src/agent.ts @@ -0,0 +1,421 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Agent, + type AgentEvent, + type AgentMessage, + type AgentContext, + type StreamFn, +} from "@earendil-works/pi-agent-core"; +import { + clampThinkingLevel, + isContextOverflow, + validateToolArguments, + type AssistantMessage, + type ImageContent, + type Message, + type Model, + type ToolResultMessage, + type Usage, +} from "@earendil-works/pi-ai"; +import { convertToLlm } from "@earendil-works/pi-coding-agent"; +import { Admission, AdmissionError } from "./admission.js"; + +export class ContextOverflowError extends Error {} + +/** Own the execution loop so even pending content never enters Pi's reducer. + * AgentSession alone persists the approved message_end events. + */ +export class AdmissionAgent extends Agent { + private readonly live; + private systemPromptCandidate = ""; + private approvedSystemPrompt = ""; + private readonly subscribers = new Set< + (event: AgentEvent, signal: AbortSignal) => Promise | void + >(); + private readonly steering: AgentMessage[] = []; + private readonly followUps: AgentMessage[] = []; + private controller?: AbortController; + private settled: Promise = Promise.resolve(); + stopped = false; + + constructor( + model: Model<"openai-completions">, + streamFn: StreamFn, + private readonly admission: Admission, + ) { + // Match Pi's default thinking level; the session's native controls can change it. + super({ + initialState: { model, thinkingLevel: clampThinkingLevel(model, "medium") }, + streamFn, + }); + // Pi's base lifecycle fields are readonly. This engine owns its own public + // state and lifecycle; it never invokes the base execution/state reducer. + const owner = this; + this.live = { + ...super.state, + // Pi rebuilds this field synchronously. Stage those writes as candidates; + // public state continues to expose only the last approved system prompt. + get systemPrompt(): string { + return owner.approvedSystemPrompt; + }, + set systemPrompt(value: string) { + owner.systemPromptCandidate = value; + }, + pendingToolCalls: new Set(), + }; + } + + async approveSystemPrompt(signal?: AbortSignal): Promise { + const approved = await this.admission.text( + "system", + this.systemPromptCandidate, + signal, + ); + signal?.throwIfAborted(); + this.approvedSystemPrompt = approved; + return approved; + } + + override get state() { + return this.live; + } + override get signal() { + return this.controller?.signal; + } + override subscribe( + listener: (event: AgentEvent, signal: AbortSignal) => Promise | void, + ) { + this.subscribers.add(listener); + return () => { + this.subscribers.delete(listener); + }; + } + override abort() { + this.controller?.abort(); + } + override waitForIdle() { + return this.settled; + } + override steer(message: AgentMessage) { + this.steering.push(message); + } + override followUp(message: AgentMessage) { + this.followUps.push(message); + } + override clearSteeringQueue() { + this.steering.length = 0; + } + override clearFollowUpQueue() { + this.followUps.length = 0; + } + override clearAllQueues() { + this.clearSteeringQueue(); + this.clearFollowUpQueue(); + } + override hasQueuedMessages() { + return this.steering.length + this.followUps.length > 0; + } + override reset() { + if (this.live.isStreaming) + throw new Error("Cancel the current operation first."); + this.live.messages = []; + this.live.errorMessage = undefined; + this.stopped = false; + this.clearAllQueues(); + } + override prompt( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): Promise { + if (images?.length) + return Promise.reject(new AdmissionError("unsupported")); + const messages: AgentMessage[] = + typeof input === "string" + ? [{ role: "user", content: input, timestamp: Date.now() }] + : Array.isArray(input) + ? input + : [input]; + return this.run(messages); + } + override continue(): Promise { + const last = this.live.messages.at(-1); + if ( + !this.hasQueuedMessages() && + last?.role !== "user" && + last?.role !== "toolResult" + ) + return Promise.reject( + new Error("There is no unfinished turn to continue."), + ); + return this.run([]); + } + + private async run(candidates: AgentMessage[]): Promise { + if (this.live.isStreaming || this.stopped) + throw new Error("Session is busy or stopped; use /new if stopped."); + this.controller = new AbortController(); + this.live.isStreaming = true; + this.live.errorMessage = undefined; + let settle!: () => void; + this.settled = new Promise((resolve) => { + settle = resolve; + }); + const published: AgentMessage[] = []; + try { + await this.emit({ type: "agent_start" }); + await this.admitBatch(candidates, published); + const steered = await this.drain( + this.steering, + this.steeringMode, + published, + ); + if (!candidates.length && !steered) + await this.drain(this.followUps, this.followUpMode, published); + for (;;) { + this.signal!.throwIfAborted(); + await this.emit({ type: "turn_start" }); + // AgentSession rebuilds system context when tools/settings change. + // Approve that snapshot before every provider call. + const systemPrompt = await this.approveSystemPrompt(this.signal); + const response = await ( + await this.streamFunction( + this.live.model, + { + systemPrompt, + messages: convertToLlm(this.live.messages), + tools: this.live.tools, + }, + { + signal: this.signal, + sessionId: this.sessionId, + reasoning: this.live.thinkingLevel === "off" ? undefined : this.live.thinkingLevel, + }, + ) + ).result(); + this.signal!.throwIfAborted(); + if (isContextOverflow(response, this.live.model.contextWindow)) + throw new ContextOverflowError("Context is too large."); + if ( + response.stopReason === "error" || + response.stopReason === "aborted" + ) + throw new Error( + "Model request failed or was cancelled; no response was saved.", + ); + const assistant = (await this.admit(response)) as AssistantMessage; + await this.publish(assistant, published); + const calls = assistant.content.filter( + (block) => block.type === "toolCall", + ); + const toolResults: ToolResultMessage[] = []; + for (let index = 0; index < calls.length; index++) { + const call = calls[index]; + try { + if (assistant.stopReason === "length") + throw new Error("Incomplete tool call."); + this.signal!.throwIfAborted(); + const tool = this.live.tools.find( + (tool) => tool.name === call.name, + ); + let args: unknown = call.arguments; + let result; + let isError = false; + this.live.pendingToolCalls.add(call.id); + await this.emit({ + type: "tool_execution_start", + toolCallId: call.id, + toolName: call.name, + args, + }); + try { + if (!tool) throw new Error("Requested tool is not available."); + // Pi's edit tool normalizes common model argument shapes in place. + // Keep that preparation separate from the already-approved message. + const prepared = structuredClone(call); + prepared.arguments = (tool.prepareArguments + ? tool.prepareArguments(prepared.arguments) + : prepared.arguments) as typeof prepared.arguments; + args = validateToolArguments(tool, prepared); + // No onUpdate callback: partial tool output is not approved yet. + result = await tool.execute(call.id, args, this.signal); + } catch (error) { + isError = true; + result = { + content: [ + { + type: "text" as const, + text: + error instanceof Error + ? error.message + : "Tool execution failed.", + }, + ], + details: undefined, + }; + } + const approved = (await this.admit({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content: result.content, + isError: isError, + timestamp: Date.now(), + })) as ToolResultMessage; + await this.publishTool(approved, published); + toolResults.push(approved); + } catch (error) { + // Close outstanding pairs with separately admitted, content-free + // failures. If admission is unavailable, require a new session. + try { + for (const pending of calls.slice(index)) { + const approved = (await this.admit({ + role: "toolResult", + toolCallId: pending.id, + toolName: pending.name, + content: [ + { + type: "text", + text: "Tool result unavailable; this turn was stopped.", + }, + ], + isError: true, + timestamp: Date.now(), + })) as ToolResultMessage; + await this.publishTool(approved, published); + } + } catch { + this.stopped = true; + } + throw error; + } + } + await this.emit({ type: "turn_end", message: assistant, toolResults }); + const steered = await this.drain( + this.steering, + this.steeringMode, + published, + ); + const followedUp = + !calls.length && + !steered && + (await this.drain(this.followUps, this.followUpMode, published)); + if (!calls.length && !steered && !followedUp) break; + // Native automatic compaction between tool turns uses the same + // session_before_compact admission hook as manual compaction. + await this.prepareNextTurnWithContext?.( + { + message: assistant, + toolResults, + context: this.context(), + newMessages: published, + }, + this.signal, + ); + } + } catch (error) { + this.clearAllQueues(); + // Never turn an unchecked exception or partial provider response into a + // persisted assistant error message. + this.live.errorMessage = + error instanceof AdmissionError + ? error.message + : "Operation stopped; no unchecked content was saved."; + if ( + error instanceof AdmissionError || + error instanceof ContextOverflowError + ) + throw error; + throw new Error(this.live.errorMessage); + } finally { + try { + await this.emit({ type: "agent_end", messages: published }); + } finally { + this.live.pendingToolCalls.clear(); + this.live.isStreaming = false; + this.controller = undefined; + settle(); + } + } + } + + private context(): AgentContext { + return { + systemPrompt: this.live.systemPrompt, + messages: this.live.messages.slice(), + tools: this.live.tools, + }; + } + private async admit(candidate: AgentMessage): Promise { + if ( + candidate.role !== "user" && + candidate.role !== "assistant" && + candidate.role !== "toolResult" + ) + throw new AdmissionError("unsupported"); + return this.admission.message(candidate, this.signal); + } + private async admitBatch( + candidates: AgentMessage[], + published: AgentMessage[], + ) { + const approved = []; + for (const candidate of candidates) + approved.push(await this.admit(candidate)); + this.signal!.throwIfAborted(); + for (const message of approved) await this.publish(message, published); + } + private async drain( + queue: AgentMessage[], + mode: string, + published: AgentMessage[], + ): Promise { + if (!queue.length) return false; + const candidates = queue.splice(0, mode === "all" ? queue.length : 1); + await this.admitBatch(candidates, published); + return true; + } + private async publish(message: Message, published: AgentMessage[]) { + this.live.messages = [...this.live.messages, message]; + published.push(message); + await this.emit({ type: "message_start", message }); + await this.emit({ type: "message_end", message }); + } + private async publishTool( + message: ToolResultMessage, + published: AgentMessage[], + ) { + this.live.pendingToolCalls.delete(message.toolCallId); + // Drop unchecked details (including edit diffs) and usage metadata. + await this.emit({ + type: "tool_execution_end", + toolCallId: message.toolCallId, + toolName: message.toolName, + result: { content: message.content }, + isError: message.isError, + }); + await this.publish(message, published); + } + private async emit(event: AgentEvent) { + for (const subscriber of this.subscribers) + await subscriber(event, this.signal!); + } +} + +export function retainedUsage(usage: Usage): Usage { + return { + input: usage.input, + output: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + totalTokens: usage.totalTokens, + cost: { + input: usage.cost.input, + output: usage.cost.output, + cacheRead: usage.cost.cacheRead, + cacheWrite: usage.cost.cacheWrite, + total: usage.cost.total, + }, + }; +} diff --git a/projects/pi-admission/pi-harness/src/cli.ts b/projects/pi-admission/pi-harness/src/cli.ts new file mode 100644 index 00000000..90de2e97 --- /dev/null +++ b/projects/pi-admission/pi-harness/src/cli.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import { parseArgs } from "node:util"; +import { InteractiveMode } from "@earendil-works/pi-coding-agent"; +import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; +import { createAdmissionRuntime } from "./session.js"; +import { configureProxy } from "./network.js"; +import { loadSelectedModel } from "./model.js"; + +async function main(): Promise { + configureProxy(); + const { values } = parseArgs({ + options: { + admission: { type: "string" }, + }, + }); + const apiKey = process.env.PI_MODEL_API_KEY; + const admissionKey = process.env.PI_ADMISSION_TOKEN; + delete process.env.PI_MODEL_API_KEY; + delete process.env.PI_ADMISSION_TOKEN; + if (!apiKey || !admissionKey || !values.admission) + throw new Error("Missing provider or admission configuration."); + const model = await loadSelectedModel(); + const runtime = await createAdmissionRuntime({ + cwd: "/sandbox/workspace", + sessionDir: "/sandbox/sessions", + agentDir: "/app/agent", + model, + apiKey, + admission: new Admission( + createHttpEvaluator(values.admission, admissionKey, randomUUID()), + ), + }); + await new InteractiveMode(runtime).run(); +} + +main().catch((error) => { + console.error( + error instanceof AdmissionError + ? error.message + : "Example failed; check configuration and service availability.", + ); + process.exitCode = 1; +}); diff --git a/projects/pi-admission/pi-harness/src/model.ts b/projects/pi-admission/pi-harness/src/model.ts new file mode 100644 index 00000000..4d942d19 --- /dev/null +++ b/projects/pi-admission/pi-harness/src/model.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + InMemoryCredentialStore, + InMemoryModelsStore, + type Model, +} from "@earendil-works/pi-ai"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; + +/** Let Pi resolve its native catalog, without writing into the read-only image. */ +export async function loadSelectedModel( + directory = "/app", +): Promise> { + const { provider, id } = JSON.parse( + await readFile(join(directory, "model-selection.json"), "utf8"), + ) as { provider: string; id: string }; + const runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsStore: new InMemoryModelsStore(), + modelsPath: join(directory, "models.json"), + }); + const error = runtime.getError(); + if (error) throw new Error(error); + const model = runtime.getModel(provider, id); + if (!model || model.api !== "openai-completions") + throw new Error("The prepared model must use openai-completions."); + return model as Model<"openai-completions">; +} diff --git a/projects/pi-admission/pi-harness/src/network.ts b/projects/pi-admission/pi-harness/src/network.ts new file mode 100644 index 00000000..c45e080c --- /dev/null +++ b/projects/pi-admission/pi-harness/src/network.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; + +/** Honor the sandbox's proxy after loading Pi and its HTTP dependencies. */ +export function configureProxy(): void { + setGlobalDispatcher( + new EnvHttpProxyAgent({ proxyTunnel: true, allowH2: false }), + ); +} diff --git a/projects/pi-admission/pi-harness/src/session.ts b/projects/pi-admission/pi-harness/src/session.ts new file mode 100644 index 00000000..78318af4 --- /dev/null +++ b/projects/pi-admission/pi-harness/src/session.ts @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolve } from "node:path"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { InMemoryCredentialStore, type Model } from "@earendil-works/pi-ai"; +import { streamSimple } from "@earendil-works/pi-ai/compat"; +import { + AgentSession, + AgentSessionRuntime, + SessionManager, + SettingsManager, + ModelRuntime, + createAgentSessionServices, + convertToLlm, + compact, + type CreateAgentSessionRuntimeFactory, + type PromptOptions, +} from "@earendil-works/pi-coding-agent"; +import { Admission, AdmissionError, RECEIPT_HEADER } from "./admission.js"; +import { + AdmissionAgent, + ContextOverflowError, + retainedUsage, +} from "./agent.js"; +import { projectTools } from "./tools.js"; + +export { projectTools } from "./tools.js"; + +export interface SessionOptions { + cwd: string; + sessionDir: string; + agentDir: string; + model: Model<"openai-completions">; + apiKey: string; + admission: Admission; + /** Deterministic integration tests use Pi's public stream/tool seams. */ + stream?: StreamFn; + compactAtTokens?: number; +} + +/** Native Pi session/persistence, with explicit guards for unsupported writes. */ +export class AdmissionSession extends AgentSession { + static async create(options: SessionOptions): Promise { + const result = await sessionFactory(options)({ + cwd: resolve(options.cwd), + agentDir: resolve(options.agentDir), + sessionManager: SessionManager.create( + resolve(options.cwd), + resolve(options.sessionDir), + ), + }); + await result.session.bindExtensions({}); + return result.session; + } + + get history() { + return structuredClone(convertToLlm(this.messages)); + } + get entries() { + return structuredClone(this.sessionManager.getEntries()); + } + override get sessionFile(): string { + return this.sessionManager.getSessionFile()!; + } + get isStopped() { + return (this.agent as AdmissionAgent).stopped; + } + + override async prompt(text: string, options?: PromptOptions): Promise { + if (this.isStopped) + throw new Error("An unfinished tool batch requires /new."); + try { + await super.prompt(text, options); + } catch (error) { + if (!(error instanceof ContextOverflowError) || !this.autoCompactionEnabled) + throw error; + // The failed provider response was never published. Compact only approved + // history, then retry that unfinished turn once. + await this.compact(); + await this.agent.continue(); + } finally { + if (!this.isStreaming) this.clearQueue(); + } + } + + // These native entry points write outside the agent's message event path. + // Keep them unavailable until each has its own pre-write admission boundary. + override async executeBash(): Promise { + return unsupported("Direct ! commands; ask the model to use the bash tool"); + } + override recordBashResult(): never { + return unsupported("Direct shell results"); + } + override async sendCustomMessage(): Promise { + return unsupported("Custom extension messages"); + } + override async navigateTree(): Promise { + return unsupported("Session branching"); + } + override async reload(): Promise { + return unsupported("Resource reload; use /new"); + } + override async setModel(): Promise { + return unsupported("Model switching"); + } + override async cycleModel(): Promise { + return unsupported("Model switching"); + } + override setSessionName(): never { + return unsupported("Session renaming"); + } +} + +/** Use Pi's real TUI runtime; /new is safe, importing unchecked history is not. */ +export async function createAdmissionRuntime( + options: SessionOptions, +): Promise { + const factory = sessionFactory(options); + const result = await factory({ + cwd: resolve(options.cwd), + agentDir: resolve(options.agentDir), + sessionManager: SessionManager.create( + resolve(options.cwd), + resolve(options.sessionDir), + ), + }); + return new AdmissionRuntime( + result.session, + result.services, + factory, + result.diagnostics, + ); +} + +function sessionFactory(options: SessionOptions) { + if ( + options.model.api !== "openai-completions" || + options.model.input.some((type) => type !== "text") || + new URL(options.model.baseUrl).protocol !== "https:" + ) + throw new AdmissionError("unsupported"); + // Preferences belong to the runtime, not to an individual conversation. + const settingsManager = SettingsManager.inMemory({ + packages: [], + enableInstallTelemetry: false, + compaction: { + enabled: true, + reserveTokens: + options.compactAtTokens === undefined + ? undefined + : options.model.contextWindow - options.compactAtTokens, + }, + retry: { enabled: false }, + }); + const stream: StreamFn = async (model, context, streamOptions) => { + const receipt = await options.admission.receipt( + context, + streamOptions?.signal, + ); + return (options.stream ?? streamSimple)(model, context, { + ...streamOptions, + apiKey: options.apiKey, + headers: { ...streamOptions?.headers, [RECEIPT_HEADER]: receipt }, + }); + }; + return async ({ + cwd, + agentDir, + sessionManager, + sessionStartEvent, + }: Parameters[0]) => { + if (sessionManager.getEntries().length) + return unsupported("Restoring existing history"); + const services = await createAgentSessionServices({ + cwd, + agentDir, + // OpenShell supplies runtime credentials; /app remains read-only. + modelRuntime: await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: null, + }), + settingsManager, + resourceLoaderOptions: { + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + extensionFactories: [ + { + name: "admission", + factory: (pi) => { + pi.on("session_before_compact", async (event) => { + // Supplying a summary or explicitly cancelling is mandatory: + // throwing from an extension handler could fall back to Pi's + // unchecked default summarizer. + try { + const summary = await compact( + event.preparation, + options.model, + options.apiKey, + undefined, + event.customInstructions, + event.signal, + session.thinkingLevel, + stream, + undefined, + { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + undefined, + sessionManager.getSessionId(), + ); + const approved = await options.admission.text( + "compaction_summary", + summary.summary, + event.signal, + ); + return { + compaction: { + summary: approved, + firstKeptEntryId: summary.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + ...(summary.usage + ? { usage: retainedUsage(summary.usage) } + : {}), + }, + }; + } catch { + return { cancel: true }; + } + }); + }, + }, + ], + }, + }); + services.modelRuntime.registerProvider(options.model.provider, { + api: options.model.api, + baseUrl: options.model.baseUrl, + models: [options.model], + }); + await services.modelRuntime.setRuntimeApiKey( + options.model.provider, + options.apiKey, + ); + const tools = projectTools(cwd); + const agent = new AdmissionAgent(options.model, stream, options.admission); + agent.sessionId = sessionManager.getSessionId(); + agent.steeringMode = settingsManager.getSteeringMode(); + agent.followUpMode = settingsManager.getFollowUpMode(); + const session = new AdmissionSession({ + agent, + cwd, + sessionManager, + sessionStartEvent, + settingsManager: services.settingsManager, + resourceLoader: services.resourceLoader, + modelRuntime: services.modelRuntime, + baseToolsOverride: Object.fromEntries( + tools.map((tool) => [tool.name, tool]), + ), + initialActiveToolNames: tools.map((tool) => tool.name), + allowedToolNames: tools.map((tool) => tool.name), + }); + // Check project instructions and skill metadata before exposing the session. + await agent.approveSystemPrompt(); + return { + session, + services, + diagnostics: services.diagnostics, + extensionsResult: services.resourceLoader.getExtensions(), + }; + }; +} + +class AdmissionRuntime extends AgentSessionRuntime { + override async switchSession(): Promise { + return unsupported("Resume"); + } + override async importFromJsonl(): Promise { + return unsupported("Import"); + } + override async fork(): Promise { + return unsupported("Fork"); + } +} + +function unsupported(feature: string): never { + throw new Error(`${feature} is not supported by this admission example.`); +} diff --git a/projects/pi-admission/pi-harness/src/tools.ts b/projects/pi-admission/pi-harness/src/tools.ts new file mode 100644 index 00000000..86224b8d --- /dev/null +++ b/projects/pi-admission/pi-harness/src/tools.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { + createReadTool, + createBashTool, + createEditTool, + createWriteTool, + createGrepTool, + createFindTool, + createLsTool, + createLocalBashOperations, +} from "@earendil-works/pi-coding-agent"; + +/** Keep bash output below Pi's automatic spill-to-file threshold. */ +export function projectTools(cwd: string): AgentTool[] { + const local = createLocalBashOperations(); + const bash = createBashTool(cwd, { + exposeSessionEnvironment: false, + operations: { + async exec(command, directory, options) { + const limit = new AbortController(); + let bytes = 0; + let lines = 0; + const result = await local.exec(command, directory, { + ...options, + signal: AbortSignal.any([ + limit.signal, + ...(options.signal ? [options.signal] : []), + ]), + onData(data) { + bytes += data.length; + lines += data.toString("utf8").split("\n").length - 1; + if (bytes > 16_000 || lines > 1000) limit.abort(); + else if (!limit.signal.aborted) options.onData(data); + }, + }); + if (limit.signal.aborted) + throw new Error( + "Bash output exceeded the example's in-memory limit.", + ); + return result; + }, + }, + }); + return [ + createReadTool(cwd), + bash, + createEditTool(cwd), + createWriteTool(cwd), + createGrepTool(cwd), + createFindTool(cwd), + createLsTool(cwd), + ]; +} diff --git a/projects/pi-admission/pi-harness/src/verify.ts b/projects/pi-admission/pi-harness/src/verify.ts new file mode 100644 index 00000000..c3e55017 --- /dev/null +++ b/projects/pi-admission/pi-harness/src/verify.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { parseArgs } from "node:util"; +import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; +import { AdmissionSession } from "./session.js"; +import { configureProxy } from "./network.js"; +import { loadSelectedModel } from "./model.js"; + +async function verify(): Promise { + configureProxy(); + const { values } = parseArgs({ options: { admission: { type: "string" } } }); + const apiKey = process.env.PI_MODEL_API_KEY; + const admissionKey = process.env.PI_ADMISSION_TOKEN; + delete process.env.PI_MODEL_API_KEY; + delete process.env.PI_ADMISSION_TOKEN; + assert.ok( + apiKey && admissionKey && values.admission, + "Missing example configuration", + ); + const model = await loadSelectedModel(); + const session = await AdmissionSession.create({ + cwd: "/sandbox/workspace", + sessionDir: "/sandbox/sessions", + agentDir: "/app/agent", + model, + apiKey, + admission: new Admission( + createHttpEvaluator(values.admission, admissionKey, randomUUID()), + ), + }); + + const raw = await fetch(`${model.baseUrl}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: model.id, + messages: [{ role: "user", content: "Harmless bypass check" }], + }), + signal: AbortSignal.timeout(30_000), + }); + assert.equal(raw.status, 403, "A request without a receipt must be blocked"); + console.log("PASS request without receipt is blocked"); + + await assert.rejects( + session.prompt("123-45-6789"), + (error) => error instanceof AdmissionError && error.kind === "denied", + ); + assert.deepEqual(session.entries, []); + console.log("PASS denied input is absent from live history"); + + await session.prompt("Reply briefly to this harmless text: alice@example.com"); + await session.prompt( + "Use the write tool to create verify-tool.txt containing only: safe text", + ); + assert.ok( + session.history.some( + (message) => + message.role === "toolResult" && + message.toolName === "write" && + !message.isError, + ), + "The model must complete one successful write-tool call", + ); + const saved = await readFile(session.sessionFile, "utf8"); + for (const snapshot of [JSON.stringify(session.history), saved]) { + assert.ok(snapshot.includes("[EMAIL]")); + assert.ok( + !snapshot.includes("alice@example.com") && + !snapshot.includes("123-45-6789"), + ); + } + console.log("PASS redaction occurs before live and saved history"); + console.log("PASS a real tool result is admitted before continuation"); + + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + assert.ok(await session.compact(), "Compaction must use the admission boundary"); + assert.ok( + !(await readFile(session.sessionFile, "utf8")).includes("alice@example.com"), + ); + console.log("PASS compaction preserves admitted history"); + console.log(`Saved evidence: ${session.sessionFile}`); +} + +if (import.meta.main) { + verify().catch(() => { + console.error( + "FAIL end-to-end verification. Check service availability, credentials, model compatibility, and the last PASS line.", + ); + process.exitCode = 1; + }); +} diff --git a/projects/pi-admission/pi-harness/test/e2e.test.ts b/projects/pi-admission/pi-harness/test/e2e.test.ts new file mode 100644 index 00000000..58c19e60 --- /dev/null +++ b/projects/pi-admission/pi-harness/test/e2e.test.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + createAssistantMessageEventStream, + type AssistantMessage, + type Context, + type Model, +} from "@earendil-works/pi-ai"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { + Admission, + type AdmissionResponse, + type Evaluate, +} from "../src/admission.js"; +import { AdmissionSession } from "../src/session.js"; + +const model: Model<"openai-completions"> = { + id: "test", + name: "Test", + provider: "test", + api: "openai-completions", + baseUrl: "https://provider.test/v1", + reasoning: false, + input: ["text"], + contextWindow: 100000, + maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, +}; +const allow: AdmissionResponse = { + decision: "allow", + replacement: null, + receipt: null, +}; + +function answer(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: model.api, + model: model.id, + provider: model.provider, + timestamp: Date.now(), + stopReason: "stop", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +function writeCall(): AssistantMessage { + return { + ...answer(""), + content: [ + { + type: "toolCall", + id: "write-1", + name: "write", + arguments: { path: "tool-output.txt", content: "written by Pi" }, + }, + ], + stopReason: "toolUse", + }; +} + +async function fixture(evaluate: Evaluate) { + const cwd = await mkdtemp(join(tmpdir(), "pi-admission-e2e-")); + const requests: Context[] = []; + const stream: StreamFn = (_model, context, options) => { + assert.equal(options?.headers?.["x-pi-admission-receipt"], "receipt"); + requests.push(structuredClone({ ...context, tools: undefined })); + const result = createAssistantMessageEventStream(); + const message = requests.length === 1 ? writeCall() : answer("Done"); + result.push({ + type: "done", + reason: message.stopReason === "toolUse" ? "toolUse" : "stop", + message, + }); + return result; + }; + const session = await AdmissionSession.create({ + cwd, + sessionDir: join(cwd, "sessions"), + agentDir: join(cwd, "agent"), + model, + apiKey: "placeholder", + admission: new Admission(evaluate), + stream, + }); + return { cwd, session, requests }; +} + +async function saved(session: AdmissionSession): Promise { + try { + return await readFile(session.sessionFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + +test("allowed and redacted input reaches the provider and saved history with a receipt", async () => { + const kinds: string[] = []; + const { cwd, session, requests } = await fixture(async (kind, body) => { + kinds.push(kind); + if (kind === "provider_context") return { ...allow, receipt: "receipt" }; + if (kind === "user_message" && body.text === "alice@example.com") { + return { + decision: "replace", + replacement: { ...body, text: "[EMAIL]" }, + receipt: null, + }; + } + return allow; + }); + + await session.prompt("plain text"); + await session.prompt("alice@example.com"); + + assert.equal(requests.length, 3); + assert.ok(kinds.includes("tool_result")); + assert.equal( + await readFile(join(cwd, "tool-output.txt"), "utf8"), + "written by Pi", + ); + const snapshots = [ + JSON.stringify(requests), + JSON.stringify(session.history), + await saved(session), + ]; + assert.ok( + snapshots.every((snapshot) => snapshot.includes("Successfully wrote")), + ); + assert.ok(snapshots.every((snapshot) => snapshot.includes("plain text"))); + assert.ok(snapshots.every((snapshot) => snapshot.includes("[EMAIL]"))); + assert.ok( + snapshots.every((snapshot) => !snapshot.includes("alice@example.com")), + ); +}); + +test("denied input never reaches the provider, live history, or saved history", async () => { + const forbidden = "123-45-6789"; + const { session, requests } = await fixture(async (kind) => + kind === "user_message" + ? { decision: "deny", replacement: null, receipt: null } + : allow, + ); + + await assert.rejects(session.prompt(forbidden)); + + assert.deepEqual(requests, []); + assert.ok(!JSON.stringify(session.history).includes(forbidden)); + assert.ok(!JSON.stringify(session.entries).includes(forbidden)); + assert.ok(!(await saved(session)).includes(forbidden)); +}); diff --git a/projects/pi-admission/pi-harness/tsconfig.json b/projects/pi-admission/pi-harness/tsconfig.json new file mode 100644 index 00000000..8969e893 --- /dev/null +++ b/projects/pi-admission/pi-harness/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/projects/pi-admission/policy.yaml b/projects/pi-admission/policy.yaml new file mode 100644 index 00000000..94a1d654 --- /dev/null +++ b/projects/pi-admission/policy.yaml @@ -0,0 +1,52 @@ +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: api.example.com # prepare replaces this from models.json. + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: POST + path: /v1/chat/completions + binaries: + - { path: /usr/bin/node } + - { path: /usr/local/bin/node } + + admission: + name: Authenticated admission API + endpoints: + - host: host.docker.internal # prepare replaces this with PI_ADMISSION_HOST. + port: 5443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: POST + path: /v1/admission + binaries: + - { path: /usr/local/bin/node } + +network_middlewares: + pi_admission: + name: Verify admitted Pi provider context + middleware: pi-admission + order: 0 + config: {} + on_error: fail_closed + endpoints: + include: + - api.example.com # prepare replaces this from models.json. diff --git a/projects/pi-admission/prepare.py b/projects/pi-admission/prepare.py new file mode 100644 index 00000000..05b32841 --- /dev/null +++ b/projects/pi-admission/prepare.py @@ -0,0 +1,345 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Prepare one host-owned demo configuration. Never run inside the sandbox.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import secrets +import shutil +import ssl +import sys +from datetime import UTC, datetime, timedelta +from http.client import HTTPSConnection +from pathlib import Path +from urllib.parse import urlparse + +import jwt +import yaml +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + + +def prepare( + example: Path, + state: Path, + host: str, + gateway_public_key: Path, + gateway_issuer: str, + model_selection: str = "", +) -> None: + """Keep keys outside the image; copy only the public CA and explicit demo files.""" + endpoint = urlparse(f"https://{host}:5443") + if endpoint.hostname != host or endpoint.port != 5443 or endpoint.path: + raise ValueError("Use a DNS hostname or IPv4 address, without a URL or port") + catalog, selection, base_url = select_model( + example / "models.json", model_selection + ) + target = urlparse(base_url) + if ( + target.scheme != "https" + or not target.hostname + or target.username + or target.query + or target.fragment + ): + raise ValueError( + "The model must use an HTTPS endpoint without credentials or query" + ) + if target.hostname == host: + raise ValueError("Model and admission endpoints must be separate") + public_key = serialization.load_pem_public_key(gateway_public_key.read_bytes()) + if not isinstance(public_key, ed25519.Ed25519PublicKey): + raise ValueError("Provide the gateway's Ed25519 public signing key") + os.umask(0o077) + state.mkdir(parents=True, exist_ok=True) + tls = state / "tls" + certificate = tls / "server/tls.crt" + if ( + not certificate.exists() + or (state / "service-host").read_text() != host + or x509.load_pem_x509_certificate(certificate.read_bytes()).not_valid_after_utc + <= datetime.now(UTC) + ): + _create_certificates(tls, host) + print("Service TLS created: install tls/ca.crt in the gateway's trust config.") + (state / "service-host").write_text(host) + model_path = target.path.rstrip("/") + "/chat/completions" + policy = yaml.safe_load((example / "policy.yaml").read_text()) + model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] + model_endpoint.update(host=target.hostname, port=target.port or 443) + model_endpoint["rules"][0]["allow"]["path"] = model_path + policy["network_policies"]["admission"]["endpoints"][0]["host"] = host + binding = policy["network_middlewares"]["pi_admission"] + binding["endpoints"]["include"] = [target.hostname] + (state / "policy.yaml").write_text(yaml.safe_dump(policy, sort_keys=False)) + for name, provider_host, port, variable in [ + ("model", target.hostname, target.port or 443, "PI_MODEL_API_KEY"), + ("admission", host, 5443, "PI_ADMISSION_TOKEN"), + ]: + profile = { + "id": f"pi-admission-{name}", + "display_name": f"Pi example {name}", + "category": "inference" if name == "model" else "other", + "credentials": [ + {"name": "token", "env_vars": [variable], "required": True} + ], + "discovery": {"credentials": ["token"]}, + "endpoints": [ + { + "host": provider_host, + "port": port, + "protocol": "rest", + "access": "read-write", + "enforcement": "enforce", + } + ], + "binaries": ["/usr/local/bin/node"], + } + (state / f"{name}-provider.yaml").write_text(yaml.safe_dump(profile)) + config_path = state / "admission.json" + token = ( + json.loads(config_path.read_text())["bearer_token"] + if config_path.exists() + else secrets.token_urlsafe(32) + ) + audience = "urn:openshell:extension:middleware:pi-admission" + config = { + "listen": "0.0.0.0:5443", + "tls_certificate": str(tls / "server/tls.crt"), + "tls_private_key": str(tls / "server/tls.key"), + "gateway_public_key": str(gateway_public_key.resolve()), + "gateway_issuer": gateway_issuer, + "gateway_audience": audience, + "middleware_name": "pi-admission", + "bearer_token": token, + "sandbox_id_file": str(state / "sandbox-id"), + "provider_target": { + "scheme": "https", + "host": target.hostname, + "port": target.port or 443, + "method": "POST", + "path": model_path, + "query": "", + }, + } + config_path.write_text(json.dumps(config, indent=2) + "\n") + # JSON string quoting is also valid for these TOML basic string values. + quote = json.dumps + registration = f"""[[openshell.supervisor.middleware]] +name = "pi-admission" +grpc_endpoint = "https://{host}:50051" +tls_ca_cert_path = {quote(str(tls / "ca.crt"))} +audience = "{audience}" +max_payload_bytes = 4194304 +timeout = "10s" +""" + (state / "middleware.toml").write_text(registration) + image = state / "image" + # Recreate only this generated build context, so removed source/config files + # cannot survive a subsequent prepare. Host keys and runtime state stay put. + if image.exists(): + shutil.rmtree(image) + image.mkdir() + shutil.copytree(example / "pi-harness/src", image / "pi-harness/src") + for name in ("package.json", "package-lock.json", "tsconfig.json"): + shutil.copyfile(example / "pi-harness" / name, image / "pi-harness" / name) + (image / "models.json").write_text(json.dumps(catalog, indent=2) + "\n") + (image / "model-selection.json").write_text(json.dumps(selection) + "\n") + shutil.copyfile(example / "sandbox/Dockerfile", image / "Dockerfile") + shutil.copyfile(tls / "ca.crt", image / "admission-ca.crt") + print(f"Selected model: {selection['provider']}/{selection['id']}") + + +def select_model( + path: Path, requested: str +) -> tuple[dict[str, object], dict[str, str], str]: + """Stage one native Pi model; provider credentials remain owned by OpenShell.""" + providers = json.loads(path.read_text()).get("providers") + if not isinstance(providers, dict): + raise ValueError( + "Use Pi's native models.json providers catalog; see models.json.example" + ) + choices = [ + (provider_id, provider, model) + for provider_id, provider in providers.items() + for model in provider.get("models", []) + if not requested or f"{provider_id}/{model['id']}" == requested + ] + if len(choices) != 1: + raise ValueError( + "Set PI_MODEL=provider/model to select exactly one declared model" + ) + provider_id, provider, model = choices[0] + overrides = provider.get("modelOverrides", {}).get(model["id"], {}) + if any(config.get("headers") for config in (provider, model, overrides)): + raise ValueError("Custom model headers are unsupported; use PI_MODEL_API_KEY") + if provider.get("oauth"): + raise ValueError( + "Custom provider authentication is unsupported; use PI_MODEL_API_KEY" + ) + if model.get("api", provider.get("api")) != "openai-completions": + raise ValueError("Select an openai-completions model for this example") + base_url = model.get("baseUrl", provider.get("baseUrl")) + if not isinstance(base_url, str): + raise ValueError("Declare the selected model's baseUrl in models.json") + selected_provider = { + key: provider[key] for key in ("api", "baseUrl", "compat") if key in provider + } + selected_provider["models"] = [model] + if overrides: + selected_provider["modelOverrides"] = {model["id"]: overrides} + return ( + {"providers": {provider_id: selected_provider}}, + {"provider": provider_id, "id": model["id"]}, + base_url, + ) + + +def _discover_gateway(gateway: dict[str, str]) -> tuple[bytes, str]: + """Use the CLI's registered endpoint and existing client TLS, never new keys.""" + endpoint = urlparse(gateway["endpoint"]) + name = gateway["name"] + if endpoint.scheme != "https" or not endpoint.hostname or gateway["auth"] != "mtls": + raise ValueError("This demo requires a registered HTTPS/mTLS gateway") + if not name or Path(name).name != name or name in (".", ".."): + raise ValueError("Invalid gateway name") + config = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + tls = config / "openshell/gateways" / name / "mtls" + context = ssl.create_default_context(cafile=str(tls / "ca.crt")) + # OpenShell's generated certificates omit extensions required by Python 3.13's + # strict X.509 mode. Retain CA/signature, expiry and hostname verification. + context.verify_flags &= ~ssl.VERIFY_X509_STRICT + context.load_cert_chain(tls / "tls.crt", tls / "tls.key") + connection = HTTPSConnection( + endpoint.hostname, endpoint.port, context=context, timeout=10 + ) + try: + print(f"Discovering gateway identity from {gateway['endpoint']}") + connection.request("GET", "/.well-known/openid-configuration") + response = connection.getresponse() + if response.status != 200: + raise ValueError(f"Gateway discovery returned HTTP {response.status}") + discovery = json.load(response) + issuer = discovery["issuer"] + if not isinstance(issuer, str) or not issuer: + raise ValueError("Gateway discovery must provide a nonempty issuer") + jwks = urlparse(discovery["jwks_uri"]) + if (jwks.scheme, jwks.netloc) != (endpoint.scheme, endpoint.netloc): + raise ValueError( + "Gateway signing keys must come from the same HTTPS origin" + ) + connection.request("GET", jwks.path + (f"?{jwks.query}" if jwks.query else "")) + response = connection.getresponse() + if response.status != 200: + raise ValueError( + f"Gateway signing-key discovery returned HTTP {response.status}" + ) + keys = json.load(response)["keys"] + if len(keys) != 1: + raise ValueError("This demo expects one gateway signing key") + key = jwt.PyJWK.from_dict(keys[0]).key + if not isinstance(key, ed25519.Ed25519PublicKey): + raise ValueError("Gateway must publish an Ed25519 public signing key") + return key.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ), issuer + finally: + connection.close() + + +def _create_certificates(tls: Path, host: str) -> None: + now = datetime.now(UTC) + ca_key = ec.generate_private_key(ec.SECP256R1()) + ca_name = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "Pi admission demo CA")] + ) + ca = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=30)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + tls.mkdir(exist_ok=True) + (tls / "ca.crt").write_bytes(ca.public_bytes(serialization.Encoding.PEM)) + # The CA key is not needed again; each setup has a 30-day local trust bundle. + key = ec.generate_private_key(ec.SECP256R1()) + certificate = ( + x509.CertificateBuilder() + .subject_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "pi-admission-service")]) + ) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=30)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("localhost"), _service_name(host)] + ), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + directory = tls / "server" + directory.mkdir(exist_ok=True) + (directory / "tls.crt").write_bytes( + certificate.public_bytes(serialization.Encoding.PEM) + ) + (directory / "tls.key").write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + + +def _service_name(host: str) -> x509.GeneralName: + try: + return x509.IPAddress(ipaddress.ip_address(host)) + except ValueError: + return x509.DNSName(host) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--host", required=True) + parser.add_argument("--gateway", required=True) + parser.add_argument("--model", default="") + args = parser.parse_args() + gateways = json.load(sys.stdin) + gateway = next((item for item in gateways if item["name"] == args.gateway), None) + if gateway is None: + parser.error("Gateway is not registered; use openshell gateway add first") + public_key, issuer = _discover_gateway(gateway) + os.umask(0o077) + args.state.mkdir(parents=True, exist_ok=True) + public_path = args.state.resolve() / "gateway-public.pem" + public_path.write_bytes(public_key) + prepare( + Path(__file__).resolve().parent, + args.state.resolve(), + args.host, + public_path, + issuer, + args.model, + ) diff --git a/projects/pi-admission/project.yaml b/projects/pi-admission/project.yaml new file mode 100644 index 00000000..8a66e9a9 --- /dev/null +++ b/projects/pi-admission/project.yaml @@ -0,0 +1 @@ +kind: use-case-example diff --git a/projects/pi-admission/pyproject.toml b/projects/pi-admission/pyproject.toml new file mode 100644 index 00000000..c67a673c --- /dev/null +++ b/projects/pi-admission/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "pi-admission-example" +version = "0.1.0" +description = "Setup helpers for the standalone OpenShell Pi admission example." +requires-python = ">=3.11" +license = "Apache-2.0" +license-files = ["LICENSE"] +dependencies = [ + "cryptography>=50,<51", + "pyjwt[crypto]>=2.10,<3", + "pyyaml>=6,<7", +] + +[dependency-groups] +dev = [ + "ruff>=0.12,<1", +] + +[tool.ruff] +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] + +[tool.uv] +required-version = ">=0.11.0" diff --git a/projects/pi-admission/sandbox/Dockerfile b/projects/pi-admission/sandbox/Dockerfile new file mode 100644 index 00000000..a86a2ca1 --- /dev/null +++ b/projects/pi-admission/sandbox/Dockerfile @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM node:22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates fd-find ripgrep \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && useradd --create-home --uid 1001 sandbox \ + && rm -rf /var/lib/apt/lists/* +COPY admission-ca.crt /usr/local/share/ca-certificates/admission-ca.crt +RUN update-ca-certificates +WORKDIR /app +COPY pi-harness/package.json pi-harness/package-lock.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY pi-harness/ ./ +RUN npm run build && mkdir /app/agent +COPY models.json /app/models.json +COPY model-selection.json /app/model-selection.json +RUN mkdir -p /sandbox/workspace /sandbox/sessions \ + && chown -R sandbox:sandbox /sandbox \ + && chmod -R a+rX /app +ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt +WORKDIR /sandbox/workspace +USER sandbox diff --git a/projects/pi-admission/uv.lock b/projects/pi-admission/uv.lock new file mode 100644 index 00000000..4bb3b2b6 --- /dev/null +++ b/projects/pi-admission/uv.lock @@ -0,0 +1,285 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[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 = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + +[[package]] +name = "pi-admission-example" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=50,<51" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, + { name = "pyyaml", specifier = ">=6,<7" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.12,<1" }] + +[[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 = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] From 4f0ebd71cf0f314339553fb838f445a518c4b0a8 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 17 Sep 2026 19:40:23 +0000 Subject: [PATCH 02/14] refactor(pi-admission): minimize launcher and middleware example --- projects/pi-admission/README.md | 192 +++++++----------- .../pi-admission/middleware/src/policy.rs | 1 - .../pi-admission/pi-harness/src/admission.ts | 4 +- projects/pi-admission/pi-harness/src/agent.ts | 170 ++++------------ .../pi-admission/pi-harness/src/session.ts | 44 +--- projects/pi-admission/pi-harness/src/tools.ts | 56 ----- .../pi-admission/pi-harness/test/e2e.test.ts | 49 ++++- projects/pi-admission/prepare.py | 1 + projects/pi-admission/project.yaml | 2 +- projects/pi-admission/sandbox/Dockerfile | 1 + projects/pi-admission/workspace/counter.js | 3 + .../pi-admission/workspace/counter.test.js | 5 + projects/pi-admission/workspace/package.json | 1 + 13 files changed, 180 insertions(+), 349 deletions(-) delete mode 100644 projects/pi-admission/pi-harness/src/tools.ts create mode 100644 projects/pi-admission/workspace/counter.js create mode 100644 projects/pi-admission/workspace/counter.test.js create mode 100644 projects/pi-admission/workspace/package.json diff --git a/projects/pi-admission/README.md b/projects/pi-admission/README.md index 0a6390fb..9b79eb10 100644 --- a/projects/pi-admission/README.md +++ b/projects/pi-admission/README.md @@ -1,81 +1,72 @@ -# Standalone Pi admission +# Minimal Pi admission spike -This use-case example runs unmodified Pi in an unmodified OpenShell sandbox and -keeps policy-denied content out of Pi's live conversation and saved JSONL. A -small Rust service makes both decisions: +This research spike shows why content policy must integrate at the agent +harness—not only at network egress. It runs an unmodified Pi coding session in +OpenShell with two boundaries: -1. Pi sends each candidate to authenticated HTTPS admission before publishing - or saving it. -2. Admission allows it unchanged, replaces an `example.com` email with - `[EMAIL]`, or denies an SSN-shaped value. -3. After the full provider context is approved, the service signs a short-lived - receipt over its ordered user/tool text projection, destination, sandbox, - and fixed policy identity. -4. OpenShell calls the same service as pre-credentials middleware. It verifies - the receipt against the intercepted request, applies the policy again, and - removes the private receipt header before provider credentials are attached. - -Blocking only at step 4 would be too late: the denied text could already be in -the local transcript. The two checks protect different boundaries. - -## Demonstration policy +```text +draft -> admission HTTPS -> Pi history and JSONL -> provider request + receipt ^ | + +-- OpenShell middleware +``` -The fixed policy is compiled once in -[`middleware/src/policy.rs`](middleware/src/policy.rs): +The launcher admits a complete user, assistant, or tool-result candidate before +publishing it. The external Rust process also signs the user/tool projection for +each model call; pre-credentials middleware checks that receipt against the +actual request before OpenShell supplies the provider credential. -| Entity | Decision | Synthetic example | -| --- | --- | --- | -| Address ending in `@example.com` | Replace with `[EMAIL]` | `alice@example.com` | -| `NNN-NN-NNNN` digits | Deny | `123-45-6789` | +The intentionally synthetic fixed policy is: -Denial is evaluated first. Patterns inspect decoded JSON strings, so JSON -escaping does not bypass them. Text block boundaries are preserved. Content in -executable tool arguments or protected reasoning metadata is denied instead of -rewritten. Egress never rewrites the provider body: it denies any remaining -matching entity. +| Match | Result | +| --- | --- | +| address ending in `@example.com` | replace with `[EMAIL]` | +| `NNN-NN-NNNN` | deny | -These regexes are intentionally incomplete. They do not validate real email -addresses or SSNs and will have both false positives and misses. Use only -synthetic data with this example. +These regexes are teaching aids, not production DLP. -## Prerequisites +## Scope -You need: +The launcher keeps Pi's native TUI, JSONL sessions, reasoning controls, and +native `read`, `bash`, `edit`, and `write` tools. Tool calls run sequentially. +The complete assistant/tool-result batch is admitted before it is published. +Tool side effects are not transactional and may exist even when a result is +denied. -- an existing HTTPS/mTLS OpenShell gateway registered in the `openshell` CLI; -- OpenShell `0.0.116` (the pinned middleware contract) or a compatible release; -- Bash, Python 3.11+, uv 0.11+, Rust 1.90+, Docker, and Node 22 only for local - harness development; -- a provider key with quota for an OpenAI-compatible Chat Completions model. +Only explicit `/compact` is supported. Automatic compaction, retries, queued +prompts, project instructions, skills, resume/import/branching, model switching, +images, extensions, and direct `!` shell commands are disabled. A prompt entered +while Pi is working is rejected rather than retained. At context exhaustion, +run `/compact` yourself. -The sample catalog uses OpenRouter. Provider use can incur normal model costs. -No keys are copied into the image or committed to this repository. +The sample workspace contains `counter.js` and one Node test so a model can +inspect, edit, and test real code without extra project scaffolding. -## Run it +## Run -From `projects/pi-admission/`: +Prerequisites are an existing HTTPS/mTLS OpenShell gateway, OpenShell `0.0.116` +or a compatible release, Bash, Python 3.11+, uv 0.11+, Rust 1.90+, Docker, Node +22 for local development, and one OpenAI-compatible Chat Completions key. ```sh cp .env.example .env cp models.json.example models.json -# Set OPENSHELL_GATEWAY, PI_ADMISSION_HOST, and PI_MODEL_API_KEY in .env. +# Fill in the three values documented in .env. ./demo.sh prepare ``` -`PI_ADMISSION_HOST` must be a DNS name or IPv4 address reachable from both the -gateway and sandbox. Do not use `localhost` for container callers. Preparation -discovers the selected gateway's issuer and public Ed25519 key over verified -mTLS, generates a 30-day local service certificate, stages one selected native -Pi model, and builds `pi-admission:local`. Host-owned state is written to -`.workspaces/` with mode `0700`/`0600` defaults. +`PI_ADMISSION_HOST` must be reachable from the gateway and sandbox. Preparation +discovers the selected gateway identity over its existing mTLS connection, +creates a 30-day local service certificate, and writes private state under +`.workspaces/`. -Run the service in one terminal: +Start the service: ```sh ./demo.sh serve ``` -In another terminal: +Then print and install the middleware registration in the gateway's +operator-owned configuration before creating the sandbox: ```sh ./demo.sh registration @@ -83,62 +74,28 @@ In another terminal: ./demo.sh launch ``` -`registration` prints the middleware TOML entry. Merge it into the selected -gateway's configuration, install the generated CA path where that gateway can -read it, and restart the gateway before running `setup`. Gateway deployment is -operator-owned; this example does not edit or restart it. - -All actions have a side-effect-free print form which does not load `.env`: - -```sh -./demo.sh --print prepare -./demo.sh --print serve -./demo.sh --print setup -./demo.sh --print launch -./demo.sh --print verify -./demo.sh --print cleanup -``` - -Try these inputs in Pi: +Useful prompts are: ```text -Hello. Briefly describe what you can do. -Please repeat alice@example.com. +Read the sample and run its test. +Change the counter to add two, update the test, and run it. +Repeat alice@example.com. 123-45-6789 -Use the write tool to create demo.txt containing a short greeting. /compact -/new /quit ``` -The email becomes `[EMAIL]` before it appears or is saved. The fictitious SSN -shape is rejected and never enters live history or JSONL. The workspace starts -empty, but Pi's project tools remain enabled: the harness admits each tool result -before publishing, saving, or continuing the model turn. Use `/session` to -locate the append-only JSONL under `/sandbox/sessions`. +Every action has a side-effect-free form that neither sources `.env` nor reveals +secrets, for example `./demo.sh --print setup`. Run the paid live check with +`./demo.sh verify`; it checks denial, redaction, a real write tool, manual +compaction, saved JSONL, and rejection of a provider request without a receipt. +It requires the running gateway, sandbox, service, and model credential. -Run the separate real-model acceptance workflow with: +Finish with `./demo.sh cleanup`. It removes only the example sandbox, sessions, +providers, and profiles. It retains host configuration, gateway registration, +and the Docker image. -```sh -./demo.sh verify -``` - -It exercises replacement, denial before history mutation, a real tool result, -manual compaction, saved JSONL, and a missing-receipt request. It requires a -running service, gateway, sandbox, and paid provider access; local tests do not -establish live provider compatibility. - -When finished: - -```sh -./demo.sh cleanup -``` - -Cleanup deletes the demo sandbox (including its sessions), provider instances -and profiles. It retains generated host configuration, the manual gateway -registration, and the Docker image. Stop `serve` separately with Ctrl-C. - -## Development checks +## Development ```sh uv run ruff format --check . @@ -155,29 +112,16 @@ npm run check npm test ``` -The automated suite is intentionally limited to five core scenarios: two Pi -harness flows plus admission transport, egress inspection, and receipt-binding -checks in the Rust service. - -The middleware scaffold, protocol, and lockfile are managed by -`openshell-middleware-manager`; do not edit its protocol by hand. - -## Supported scope and limitations - -The harness keeps Pi's native TUI, sequential tools and tool continuations, -reasoning, manual/automatic compaction, model serialization, prompt-cache -fields, and native session ownership. Allowed native messages remain unchanged. -Candidate buffers and queues are not history. +The suite is deliberately bounded: two launcher end-to-end flows and focused +admission transport, egress parsing, and receipt-binding checks. The protobuf, +manifest, and lockfile are generated or managed by +`openshell-middleware-manager`; do not edit the protocol by hand. -This POC is text-only and supports one prepared OpenAI-compatible Chat -Completions model. Project instructions, skills, shell shortcuts, resume/import, -branching, renaming, live model switching, resource reload, and arbitrary -extensions are disabled. Unsupported request structures fail closed. -It does not inspect across split content blocks or decode arbitrary encodings. -Opaque provider metadata is preserved but is not claimed to be fully understood. +## Limits -Receipts bind the ordered user/tool projection, not every byte or all -assistant/system history, and do not prove that the harness itself ran. -Tool-result admission cannot reverse tool side effects. The history guarantee -is for this controlled harness, not compromised same-authority code. This is a -readable security example, not production DLP or identity validation. +The supported request is uncompressed, streaming, text-only Chat Completions. +Unknown shapes fail closed. Receipts cover ordered user/tool text, destination, +sandbox, middleware, policy, and expiry—not the full transcript or every HTTP +byte. Assistant/reasoning text is locally admitted and scanned at egress but is +not receipt-bound. The guarantee applies to this controlled launcher, not +compromised same-authority code, filesystem contents, or reversible tool effects. diff --git a/projects/pi-admission/middleware/src/policy.rs b/projects/pi-admission/middleware/src/policy.rs index 6164c596..1597db19 100644 --- a/projects/pi-admission/middleware/src/policy.rs +++ b/projects/pi-admission/middleware/src/policy.rs @@ -47,7 +47,6 @@ pub(crate) fn evaluate_candidate(kind: &str, mut body: Value) -> CandidateDecisi } let result = match kind { "user_message" => edit_message(&mut body, "user"), - "system_context" => edit_message(&mut body, "system"), "compaction_summary" => edit_message(&mut body, "compaction_summary"), "tool_result" => edit_tool_result(&mut body), "assistant_message" => edit_assistant(&mut body), diff --git a/projects/pi-admission/pi-harness/src/admission.ts b/projects/pi-admission/pi-harness/src/admission.ts index 0e2328b5..e164bf07 100644 --- a/projects/pi-admission/pi-harness/src/admission.ts +++ b/projects/pi-admission/pi-harness/src/admission.ts @@ -12,10 +12,9 @@ import type { export const RECEIPT_HEADER = "x-pi-admission-receipt"; export const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; -export type TextOrigin = "user" | "system" | "compaction_summary"; +export type TextOrigin = "user" | "compaction_summary"; export type AdmissionKind = | "user_message" - | "system_context" | "compaction_summary" | "assistant_message" | "tool_result" @@ -125,7 +124,6 @@ export class Admission { ): Promise { const kind = { user: "user_message", - system: "system_context", compaction_summary: "compaction_summary", } as const; const envelope = { diff --git a/projects/pi-admission/pi-harness/src/agent.ts b/projects/pi-admission/pi-harness/src/agent.ts index f037c2e2..e3721037 100644 --- a/projects/pi-admission/pi-harness/src/agent.ts +++ b/projects/pi-admission/pi-harness/src/agent.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isDeepStrictEqual } from "node:util"; import { Agent, type AgentEvent, type AgentMessage, - type AgentContext, type StreamFn, } from "@earendil-works/pi-agent-core"; import { @@ -29,13 +29,9 @@ export class ContextOverflowError extends Error {} */ export class AdmissionAgent extends Agent { private readonly live; - private systemPromptCandidate = ""; - private approvedSystemPrompt = ""; private readonly subscribers = new Set< (event: AgentEvent, signal: AbortSignal) => Promise | void >(); - private readonly steering: AgentMessage[] = []; - private readonly followUps: AgentMessage[] = []; private controller?: AbortController; private settled: Promise = Promise.resolve(); stopped = false; @@ -50,34 +46,13 @@ export class AdmissionAgent extends Agent { initialState: { model, thinkingLevel: clampThinkingLevel(model, "medium") }, streamFn, }); - // Pi's base lifecycle fields are readonly. This engine owns its own public - // state and lifecycle; it never invokes the base execution/state reducer. - const owner = this; + // Pi's session persists only the approved events emitted by this loop. this.live = { ...super.state, - // Pi rebuilds this field synchronously. Stage those writes as candidates; - // public state continues to expose only the last approved system prompt. - get systemPrompt(): string { - return owner.approvedSystemPrompt; - }, - set systemPrompt(value: string) { - owner.systemPromptCandidate = value; - }, pendingToolCalls: new Set(), }; } - async approveSystemPrompt(signal?: AbortSignal): Promise { - const approved = await this.admission.text( - "system", - this.systemPromptCandidate, - signal, - ); - signal?.throwIfAborted(); - this.approvedSystemPrompt = approved; - return approved; - } - override get state() { return this.live; } @@ -99,23 +74,18 @@ export class AdmissionAgent extends Agent { return this.settled; } override steer(message: AgentMessage) { - this.steering.push(message); + void message; + throw new Error("Agent is busy; wait or cancel the active turn."); } override followUp(message: AgentMessage) { - this.followUps.push(message); - } - override clearSteeringQueue() { - this.steering.length = 0; - } - override clearFollowUpQueue() { - this.followUps.length = 0; - } - override clearAllQueues() { - this.clearSteeringQueue(); - this.clearFollowUpQueue(); + void message; + throw new Error("Agent is busy; wait or cancel the active turn."); } + override clearSteeringQueue() {} + override clearFollowUpQueue() {} + override clearAllQueues() {} override hasQueuedMessages() { - return this.steering.length + this.followUps.length > 0; + return false; } override reset() { if (this.live.isStreaming) @@ -123,7 +93,6 @@ export class AdmissionAgent extends Agent { this.live.messages = []; this.live.errorMessage = undefined; this.stopped = false; - this.clearAllQueues(); } override prompt( input: string | AgentMessage | AgentMessage[], @@ -140,16 +109,7 @@ export class AdmissionAgent extends Agent { return this.run(messages); } override continue(): Promise { - const last = this.live.messages.at(-1); - if ( - !this.hasQueuedMessages() && - last?.role !== "user" && - last?.role !== "toolResult" - ) - return Promise.reject( - new Error("There is no unfinished turn to continue."), - ); - return this.run([]); + return Promise.reject(new Error("Automatic continuation is disabled.")); } private async run(candidates: AgentMessage[]): Promise { @@ -166,24 +126,14 @@ export class AdmissionAgent extends Agent { try { await this.emit({ type: "agent_start" }); await this.admitBatch(candidates, published); - const steered = await this.drain( - this.steering, - this.steeringMode, - published, - ); - if (!candidates.length && !steered) - await this.drain(this.followUps, this.followUpMode, published); for (;;) { this.signal!.throwIfAborted(); await this.emit({ type: "turn_start" }); - // AgentSession rebuilds system context when tools/settings change. - // Approve that snapshot before every provider call. - const systemPrompt = await this.approveSystemPrompt(this.signal); const response = await ( await this.streamFunction( this.live.model, { - systemPrompt, + systemPrompt: this.live.systemPrompt, messages: convertToLlm(this.live.messages), tools: this.live.tools, }, @@ -196,7 +146,9 @@ export class AdmissionAgent extends Agent { ).result(); this.signal!.throwIfAborted(); if (isContextOverflow(response, this.live.model.contextWindow)) - throw new ContextOverflowError("Context is too large."); + throw new ContextOverflowError( + "Context is too large; run /compact and retry.", + ); if ( response.stopReason === "error" || response.stopReason === "aborted" @@ -205,13 +157,11 @@ export class AdmissionAgent extends Agent { "Model request failed or was cancelled; no response was saved.", ); const assistant = (await this.admit(response)) as AssistantMessage; - await this.publish(assistant, published); const calls = assistant.content.filter( (block) => block.type === "toolCall", ); const toolResults: ToolResultMessage[] = []; - for (let index = 0; index < calls.length; index++) { - const call = calls[index]; + for (const call of calls) { try { if (assistant.stopReason === "length") throw new Error("Incomplete tool call."); @@ -238,9 +188,28 @@ export class AdmissionAgent extends Agent { ? tool.prepareArguments(prepared.arguments) : prepared.arguments) as typeof prepared.arguments; args = validateToolArguments(tool, prepared); + if (!isDeepStrictEqual(args, call.arguments)) { + const checked = (await this.admit({ + ...assistant, + content: assistant.content.map((block) => + block.type === "toolCall" && block.id === call.id + ? { ...block, arguments: args as typeof block.arguments } + : block, + ), + })) as AssistantMessage; + const checkedCall = checked.content.find( + (block) => block.type === "toolCall" && block.id === call.id, + ); + if ( + checkedCall?.type !== "toolCall" || + !isDeepStrictEqual(checkedCall.arguments, args) + ) + throw new AdmissionError("invalid"); + } // No onUpdate callback: partial tool output is not approved yet. result = await tool.execute(call.id, args, this.signal); } catch (error) { + this.signal!.throwIfAborted(); isError = true; result = { content: [ @@ -263,56 +232,20 @@ export class AdmissionAgent extends Agent { isError: isError, timestamp: Date.now(), })) as ToolResultMessage; - await this.publishTool(approved, published); toolResults.push(approved); } catch (error) { - // Close outstanding pairs with separately admitted, content-free - // failures. If admission is unavailable, require a new session. - try { - for (const pending of calls.slice(index)) { - const approved = (await this.admit({ - role: "toolResult", - toolCallId: pending.id, - toolName: pending.name, - content: [ - { - type: "text", - text: "Tool result unavailable; this turn was stopped.", - }, - ], - isError: true, - timestamp: Date.now(), - })) as ToolResultMessage; - await this.publishTool(approved, published); - } - } catch { - this.stopped = true; - } + // Tool effects cannot be rolled back. Keep the incomplete batch out + // of history and stop instead of attempting replay. + if (!this.signal!.aborted) this.stopped = true; throw error; } } + this.signal!.throwIfAborted(); + await this.publish(assistant, published); + for (const result of toolResults) + await this.publishTool(result, published); await this.emit({ type: "turn_end", message: assistant, toolResults }); - const steered = await this.drain( - this.steering, - this.steeringMode, - published, - ); - const followedUp = - !calls.length && - !steered && - (await this.drain(this.followUps, this.followUpMode, published)); - if (!calls.length && !steered && !followedUp) break; - // Native automatic compaction between tool turns uses the same - // session_before_compact admission hook as manual compaction. - await this.prepareNextTurnWithContext?.( - { - message: assistant, - toolResults, - context: this.context(), - newMessages: published, - }, - this.signal, - ); + if (!calls.length) break; } } catch (error) { this.clearAllQueues(); @@ -340,13 +273,6 @@ export class AdmissionAgent extends Agent { } } - private context(): AgentContext { - return { - systemPrompt: this.live.systemPrompt, - messages: this.live.messages.slice(), - tools: this.live.tools, - }; - } private async admit(candidate: AgentMessage): Promise { if ( candidate.role !== "user" && @@ -366,16 +292,6 @@ export class AdmissionAgent extends Agent { this.signal!.throwIfAborted(); for (const message of approved) await this.publish(message, published); } - private async drain( - queue: AgentMessage[], - mode: string, - published: AgentMessage[], - ): Promise { - if (!queue.length) return false; - const candidates = queue.splice(0, mode === "all" ? queue.length : 1); - await this.admitBatch(candidates, published); - return true; - } private async publish(message: Message, published: AgentMessage[]) { this.live.messages = [...this.live.messages, message]; published.push(message); diff --git a/projects/pi-admission/pi-harness/src/session.ts b/projects/pi-admission/pi-harness/src/session.ts index 78318af4..4f0624d5 100644 --- a/projects/pi-admission/pi-harness/src/session.ts +++ b/projects/pi-admission/pi-harness/src/session.ts @@ -18,14 +18,7 @@ import { type PromptOptions, } from "@earendil-works/pi-coding-agent"; import { Admission, AdmissionError, RECEIPT_HEADER } from "./admission.js"; -import { - AdmissionAgent, - ContextOverflowError, - retainedUsage, -} from "./agent.js"; -import { projectTools } from "./tools.js"; - -export { projectTools } from "./tools.js"; +import { AdmissionAgent, retainedUsage } from "./agent.js"; export interface SessionOptions { cwd: string; @@ -36,7 +29,6 @@ export interface SessionOptions { admission: Admission; /** Deterministic integration tests use Pi's public stream/tool seams. */ stream?: StreamFn; - compactAtTokens?: number; } /** Native Pi session/persistence, with explicit guards for unsupported writes. */ @@ -68,20 +60,11 @@ export class AdmissionSession extends AgentSession { } override async prompt(text: string, options?: PromptOptions): Promise { + if (this.isStreaming) + throw new Error("Agent is busy; wait or cancel the active turn."); if (this.isStopped) - throw new Error("An unfinished tool batch requires /new."); - try { - await super.prompt(text, options); - } catch (error) { - if (!(error instanceof ContextOverflowError) || !this.autoCompactionEnabled) - throw error; - // The failed provider response was never published. Compact only approved - // history, then retry that unfinished turn once. - await this.compact(); - await this.agent.continue(); - } finally { - if (!this.isStreaming) this.clearQueue(); - } + throw new Error("The session stopped after an incomplete tool batch; restart Pi."); + await super.prompt(text, { ...options, streamingBehavior: undefined }); } // These native entry points write outside the agent's message event path. @@ -145,11 +128,7 @@ function sessionFactory(options: SessionOptions) { packages: [], enableInstallTelemetry: false, compaction: { - enabled: true, - reserveTokens: - options.compactAtTokens === undefined - ? undefined - : options.model.contextWindow - options.compactAtTokens, + enabled: false, }, retry: { enabled: false }, }); @@ -241,7 +220,6 @@ function sessionFactory(options: SessionOptions) { options.model.provider, options.apiKey, ); - const tools = projectTools(cwd); const agent = new AdmissionAgent(options.model, stream, options.admission); agent.sessionId = sessionManager.getSessionId(); agent.steeringMode = settingsManager.getSteeringMode(); @@ -254,14 +232,7 @@ function sessionFactory(options: SessionOptions) { settingsManager: services.settingsManager, resourceLoader: services.resourceLoader, modelRuntime: services.modelRuntime, - baseToolsOverride: Object.fromEntries( - tools.map((tool) => [tool.name, tool]), - ), - initialActiveToolNames: tools.map((tool) => tool.name), - allowedToolNames: tools.map((tool) => tool.name), }); - // Check project instructions and skill metadata before exposing the session. - await agent.approveSystemPrompt(); return { session, services, @@ -272,6 +243,9 @@ function sessionFactory(options: SessionOptions) { } class AdmissionRuntime extends AgentSessionRuntime { + override async newSession(): Promise { + return unsupported("New sessions; restart the launcher"); + } override async switchSession(): Promise { return unsupported("Resume"); } diff --git a/projects/pi-admission/pi-harness/src/tools.ts b/projects/pi-admission/pi-harness/src/tools.ts deleted file mode 100644 index 86224b8d..00000000 --- a/projects/pi-admission/pi-harness/src/tools.ts +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { - createReadTool, - createBashTool, - createEditTool, - createWriteTool, - createGrepTool, - createFindTool, - createLsTool, - createLocalBashOperations, -} from "@earendil-works/pi-coding-agent"; - -/** Keep bash output below Pi's automatic spill-to-file threshold. */ -export function projectTools(cwd: string): AgentTool[] { - const local = createLocalBashOperations(); - const bash = createBashTool(cwd, { - exposeSessionEnvironment: false, - operations: { - async exec(command, directory, options) { - const limit = new AbortController(); - let bytes = 0; - let lines = 0; - const result = await local.exec(command, directory, { - ...options, - signal: AbortSignal.any([ - limit.signal, - ...(options.signal ? [options.signal] : []), - ]), - onData(data) { - bytes += data.length; - lines += data.toString("utf8").split("\n").length - 1; - if (bytes > 16_000 || lines > 1000) limit.abort(); - else if (!limit.signal.aborted) options.onData(data); - }, - }); - if (limit.signal.aborted) - throw new Error( - "Bash output exceeded the example's in-memory limit.", - ); - return result; - }, - }, - }); - return [ - createReadTool(cwd), - bash, - createEditTool(cwd), - createWriteTool(cwd), - createGrepTool(cwd), - createFindTool(cwd), - createLsTool(cwd), - ]; -} diff --git a/projects/pi-admission/pi-harness/test/e2e.test.ts b/projects/pi-admission/pi-harness/test/e2e.test.ts index 58c19e60..e68a3b32 100644 --- a/projects/pi-admission/pi-harness/test/e2e.test.ts +++ b/projects/pi-admission/pi-harness/test/e2e.test.ts @@ -26,7 +26,7 @@ const model: Model<"openai-completions"> = { provider: "test", api: "openai-completions", baseUrl: "https://provider.test/v1", - reasoning: false, + reasoning: true, input: ["text"], contextWindow: 100000, maxTokens: 4096, @@ -62,11 +62,13 @@ function writeCall(): AssistantMessage { return { ...answer(""), content: [ + { type: "thinking", thinking: "Use the native write tool." }, { type: "toolCall", id: "write-1", name: "write", arguments: { path: "tool-output.txt", content: "written by Pi" }, + thoughtSignature: "opaque-replay-data", }, ], stopReason: "toolUse", @@ -78,6 +80,7 @@ async function fixture(evaluate: Evaluate) { const requests: Context[] = []; const stream: StreamFn = (_model, context, options) => { assert.equal(options?.headers?.["x-pi-admission-receipt"], "receipt"); + assert.equal(options?.reasoning, "medium"); requests.push(structuredClone({ ...context, tools: undefined })); const result = createAssistantMessageEventStream(); const message = requests.length === 1 ? writeCall() : answer("Done"); @@ -111,9 +114,17 @@ async function saved(session: AdmissionSession): Promise { test("allowed and redacted input reaches the provider and saved history with a receipt", async () => { const kinds: string[] = []; + let releaseTool!: () => void; + let toolPending!: () => void; + const toolGate = new Promise((resolve) => (releaseTool = resolve)); + const pending = new Promise((resolve) => (toolPending = resolve)); const { cwd, session, requests } = await fixture(async (kind, body) => { kinds.push(kind); if (kind === "provider_context") return { ...allow, receipt: "receipt" }; + if (kind === "tool_result") { + toolPending(); + await toolGate; + } if (kind === "user_message" && body.text === "alice@example.com") { return { decision: "replace", @@ -124,7 +135,12 @@ test("allowed and redacted input reaches the provider and saved history with a r return allow; }); - await session.prompt("plain text"); + assert.deepEqual(session.getActiveToolNames(), ["read", "bash", "edit", "write"]); + const firstTurn = session.prompt("plain text"); + await pending; + assert.equal(session.history.some((message) => message.role === "assistant"), false); + releaseTool(); + await firstTurn; await session.prompt("alice@example.com"); assert.equal(requests.length, 3); @@ -142,10 +158,19 @@ test("allowed and redacted input reaches the provider and saved history with a r snapshots.every((snapshot) => snapshot.includes("Successfully wrote")), ); assert.ok(snapshots.every((snapshot) => snapshot.includes("plain text"))); + assert.ok(snapshots.every((snapshot) => snapshot.includes("opaque-replay-data"))); assert.ok(snapshots.every((snapshot) => snapshot.includes("[EMAIL]"))); assert.ok( snapshots.every((snapshot) => !snapshot.includes("alice@example.com")), ); + + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + assert.ok(await session.compact()); + assert.ok(kinds.includes("compaction_summary")); + const compaction = session.entries.find((entry) => entry.type === "compaction"); + assert.ok( + compaction && (!("details" in compaction) || compaction.details === undefined), + ); }); test("denied input never reaches the provider, live history, or saved history", async () => { @@ -162,4 +187,24 @@ test("denied input never reaches the provider, live history, or saved history", assert.ok(!JSON.stringify(session.history).includes(forbidden)); assert.ok(!JSON.stringify(session.entries).includes(forbidden)); assert.ok(!(await saved(session)).includes(forbidden)); + + let release!: () => void; + let admissionPending!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const pending = new Promise((resolve) => (admissionPending = resolve)); + const cancelled = await fixture(async (kind) => { + if (kind === "user_message") { + admissionPending(); + await gate; + } + return kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow; + }); + const turn = cancelled.session.prompt("cancel me"); + await pending; + await assert.rejects(cancelled.session.prompt("busy"), /busy/); + const abort = cancelled.session.abort(); + release(); + await abort; + await assert.rejects(turn); + assert.equal(JSON.stringify(cancelled.session.history).includes("cancel me"), false); }); diff --git a/projects/pi-admission/prepare.py b/projects/pi-admission/prepare.py index 05b32841..eed7d4f9 100644 --- a/projects/pi-admission/prepare.py +++ b/projects/pi-admission/prepare.py @@ -153,6 +153,7 @@ def prepare( (image / "models.json").write_text(json.dumps(catalog, indent=2) + "\n") (image / "model-selection.json").write_text(json.dumps(selection) + "\n") shutil.copyfile(example / "sandbox/Dockerfile", image / "Dockerfile") + shutil.copytree(example / "workspace", image / "workspace") shutil.copyfile(tls / "ca.crt", image / "admission-ca.crt") print(f"Selected model: {selection['provider']}/{selection['id']}") diff --git a/projects/pi-admission/project.yaml b/projects/pi-admission/project.yaml index 8a66e9a9..23805731 100644 --- a/projects/pi-admission/project.yaml +++ b/projects/pi-admission/project.yaml @@ -1 +1 @@ -kind: use-case-example +kind: research-spike diff --git a/projects/pi-admission/sandbox/Dockerfile b/projects/pi-admission/sandbox/Dockerfile index a86a2ca1..6df2f878 100644 --- a/projects/pi-admission/sandbox/Dockerfile +++ b/projects/pi-admission/sandbox/Dockerfile @@ -17,6 +17,7 @@ COPY pi-harness/ ./ RUN npm run build && mkdir /app/agent COPY models.json /app/models.json COPY model-selection.json /app/model-selection.json +COPY workspace/ /sandbox/workspace/ RUN mkdir -p /sandbox/workspace /sandbox/sessions \ && chown -R sandbox:sandbox /sandbox \ && chmod -R a+rX /app diff --git a/projects/pi-admission/workspace/counter.js b/projects/pi-admission/workspace/counter.js new file mode 100644 index 00000000..160fdd74 --- /dev/null +++ b/projects/pi-admission/workspace/counter.js @@ -0,0 +1,3 @@ +export function increment(value) { + return value + 1; +} diff --git a/projects/pi-admission/workspace/counter.test.js b/projects/pi-admission/workspace/counter.test.js new file mode 100644 index 00000000..b0b00785 --- /dev/null +++ b/projects/pi-admission/workspace/counter.test.js @@ -0,0 +1,5 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { increment } from "./counter.js"; + +test("increments a number", () => assert.equal(increment(1), 2)); diff --git a/projects/pi-admission/workspace/package.json b/projects/pi-admission/workspace/package.json new file mode 100644 index 00000000..9e50534e --- /dev/null +++ b/projects/pi-admission/workspace/package.json @@ -0,0 +1 @@ +{"type":"module","scripts":{"test":"node --test"}} From 0eeab181aec1f32de13462cdb86dba31eae2a333 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 18 Sep 2026 15:10:05 +0000 Subject: [PATCH 03/14] Fix startup and native TUI issues exposed by isolated demo QA --- projects/research/pi-admission/README.md | 3 + .../pi-admission/middleware/Cargo.lock | 64 ------------------- .../pi-admission/middleware/Cargo.toml | 2 +- .../pi-admission/pi-harness/package-lock.json | 1 + .../pi-admission/pi-harness/package.json | 1 + .../pi-admission/pi-harness/src/agent.ts | 2 +- .../pi-admission/pi-harness/src/session.ts | 55 ++++++++++++---- 7 files changed, 51 insertions(+), 77 deletions(-) diff --git a/projects/research/pi-admission/README.md b/projects/research/pi-admission/README.md index 9b79eb10..800dd960 100644 --- a/projects/research/pi-admission/README.md +++ b/projects/research/pi-admission/README.md @@ -32,6 +32,9 @@ The complete assistant/tool-result batch is admitted before it is published. Tool side effects are not transactional and may exist even when a result is denied. +Edit results show admitted text rather than Pi's file-derived diff preview, +which would read content outside the admission boundary. + Only explicit `/compact` is supported. Automatic compaction, retries, queued prompts, project instructions, skills, resume/import/branching, model switching, images, extensions, and direct `!` shell commands are disabled. A prompt entered diff --git a/projects/research/pi-admission/middleware/Cargo.lock b/projects/research/pi-admission/middleware/Cargo.lock index 00b71e51..f6fe9083 100644 --- a/projects/research/pi-admission/middleware/Cargo.lock +++ b/projects/research/pi-admission/middleware/Cargo.lock @@ -58,29 +58,6 @@ dependencies = [ "cc", ] -[[package]] -name = "aws-lc-rs" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "axum" version = "0.8.9" @@ -201,8 +178,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -212,15 +187,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -300,12 +266,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "ed25519" version = "2.2.3" @@ -408,12 +368,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures-channel" version = "0.3.34" @@ -650,16 +604,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - [[package]] name = "js-sys" version = "0.3.105" @@ -880,12 +824,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - [[package]] name = "powerfmt" version = "0.2.0" @@ -1118,7 +1056,6 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -1143,7 +1080,6 @@ version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", diff --git a/projects/research/pi-admission/middleware/Cargo.toml b/projects/research/pi-admission/middleware/Cargo.toml index 68cd7029..b7f83492 100644 --- a/projects/research/pi-admission/middleware/Cargo.toml +++ b/projects/research/pi-admission/middleware/Cargo.toml @@ -11,7 +11,7 @@ name = "pi_admission" [dependencies] axum = "0.8" -axum-server = { version = "0.8", features = ["tls-rustls"] } +axum-server = { version = "0.8", features = ["tls-rustls-no-provider"] } base64 = "0.22" bytes = "1" ed25519-dalek = { version = "2", features = ["pem", "pkcs8", "rand_core"] } diff --git a/projects/research/pi-admission/pi-harness/package-lock.json b/projects/research/pi-admission/pi-harness/package-lock.json index a65285d4..d07ed866 100644 --- a/projects/research/pi-admission/pi-harness/package-lock.json +++ b/projects/research/pi-admission/pi-harness/package-lock.json @@ -10,6 +10,7 @@ "@earendil-works/pi-agent-core": "0.85.1", "@earendil-works/pi-ai": "0.85.1", "@earendil-works/pi-coding-agent": "0.85.1", + "@earendil-works/pi-tui": "0.85.1", "undici": "8.9.0" }, "devDependencies": { diff --git a/projects/research/pi-admission/pi-harness/package.json b/projects/research/pi-admission/pi-harness/package.json index a074ca28..09efbee0 100644 --- a/projects/research/pi-admission/pi-harness/package.json +++ b/projects/research/pi-admission/pi-harness/package.json @@ -16,6 +16,7 @@ "@earendil-works/pi-agent-core": "0.85.1", "@earendil-works/pi-ai": "0.85.1", "@earendil-works/pi-coding-agent": "0.85.1", + "@earendil-works/pi-tui": "0.85.1", "undici": "8.9.0" }, "devDependencies": { diff --git a/projects/research/pi-admission/pi-harness/src/agent.ts b/projects/research/pi-admission/pi-harness/src/agent.ts index e3721037..7122130d 100644 --- a/projects/research/pi-admission/pi-harness/src/agent.ts +++ b/projects/research/pi-admission/pi-harness/src/agent.ts @@ -114,7 +114,7 @@ export class AdmissionAgent extends Agent { private async run(candidates: AgentMessage[]): Promise { if (this.live.isStreaming || this.stopped) - throw new Error("Session is busy or stopped; use /new if stopped."); + throw new Error("Session is busy or stopped; restart Pi if stopped."); this.controller = new AbortController(); this.live.isStreaming = true; this.live.errorMessage = undefined; diff --git a/projects/research/pi-admission/pi-harness/src/session.ts b/projects/research/pi-admission/pi-harness/src/session.ts index 4f0624d5..8857c439 100644 --- a/projects/research/pi-admission/pi-harness/src/session.ts +++ b/projects/research/pi-admission/pi-harness/src/session.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; import type { StreamFn } from "@earendil-works/pi-agent-core"; import { InMemoryCredentialStore, type Model } from "@earendil-works/pi-ai"; import { streamSimple } from "@earendil-works/pi-ai/compat"; +import { Text } from "@earendil-works/pi-tui"; import { AgentSession, AgentSessionRuntime, @@ -16,6 +17,7 @@ import { compact, type CreateAgentSessionRuntimeFactory, type PromptOptions, + type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import { Admission, AdmissionError, RECEIPT_HEADER } from "./admission.js"; import { AdmissionAgent, retainedUsage } from "./agent.js"; @@ -60,13 +62,36 @@ export class AdmissionSession extends AgentSession { } override async prompt(text: string, options?: PromptOptions): Promise { - if (this.isStreaming) + if (this.isStreaming) { + if (this.extensionRunner.hasUI()) { + this.extensionRunner.getUIContext().notify( + "Agent is busy; wait or cancel the active turn.", "warning", + ); + return; + } throw new Error("Agent is busy; wait or cancel the active turn."); + } if (this.isStopped) throw new Error("The session stopped after an incomplete tool batch; restart Pi."); await super.prompt(text, { ...options, streamingBehavior: undefined }); } + override getToolDefinition(name: string): ToolDefinition | undefined { + const definition = super.getToolDefinition(name); + if (name !== "edit" || !definition) return definition; + // Pi's edit preview rereads the file, outside admission and after execution. + // Render only the admitted arguments/result; keep native tool execution. + return { + ...definition, + renderShell: "default", + renderCall: (args) => + new Text(`edit ${(args as { path?: string }).path ?? ""}`, 0, 0), + renderResult: (result) => + new Text(result.content.filter((part) => part.type === "text") + .map((part) => part.text).join("\n"), 0, 0), + }; + } + // These native entry points write outside the agent's message event path. // Keep them unavailable until each has its own pre-write admission boundary. override async executeBash(): Promise { @@ -82,7 +107,7 @@ export class AdmissionSession extends AgentSession { return unsupported("Session branching"); } override async reload(): Promise { - return unsupported("Resource reload; use /new"); + return unsupported("Resource reload; restart the launcher"); } override async setModel(): Promise { return unsupported("Model switching"); @@ -95,7 +120,7 @@ export class AdmissionSession extends AgentSession { } } -/** Use Pi's real TUI runtime; /new is safe, importing unchecked history is not. */ +/** Use Pi's real TUI runtime without importing unchecked history. */ export async function createAdmissionRuntime( options: SessionOptions, ): Promise { @@ -162,6 +187,8 @@ function sessionFactory(options: SessionOptions) { settingsManager, resourceLoaderOptions: { noExtensions: true, + noSkills: true, + noContextFiles: true, noPromptTemplates: true, noThemes: true, extensionFactories: [ @@ -243,17 +270,23 @@ function sessionFactory(options: SessionOptions) { } class AdmissionRuntime extends AgentSessionRuntime { - override async newSession(): Promise { - return unsupported("New sessions; restart the launcher"); + override async newSession() { + return this.cancelChange("New sessions; restart the launcher"); + } + override async switchSession() { + return this.cancelChange("Resume"); } - override async switchSession(): Promise { - return unsupported("Resume"); + override async importFromJsonl() { + return this.cancelChange("Import"); } - override async importFromJsonl(): Promise { - return unsupported("Import"); + override async fork() { + return this.cancelChange("Fork"); } - override async fork(): Promise { - return unsupported("Fork"); + private cancelChange(feature: string) { + this.session.extensionRunner.getUIContext().notify( + `${feature} is not supported by this admission example.`, "warning", + ); + return { cancelled: true }; } } From 7adf14dae012b3d915bfe741a76e3c21ba80db1d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 18 Sep 2026 15:13:21 +0000 Subject: [PATCH 04/14] Enable JWT verification backend for gateway registration --- projects/research/pi-admission/README.md | 1 + .../pi-admission/middleware/Cargo.lock | 239 ++++++++++++++++++ .../pi-admission/middleware/Cargo.toml | 2 +- .../pi-admission/middleware/src/auth.rs | 44 ++++ 4 files changed, 285 insertions(+), 1 deletion(-) diff --git a/projects/research/pi-admission/README.md b/projects/research/pi-admission/README.md index 800dd960..249d8248 100644 --- a/projects/research/pi-admission/README.md +++ b/projects/research/pi-admission/README.md @@ -112,6 +112,7 @@ cargo test --locked cd ../pi-harness npm ci npm run check +npm run build npm test ``` diff --git a/projects/research/pi-admission/middleware/Cargo.lock b/projects/research/pi-admission/middleware/Cargo.lock index f6fe9083..6ca1728e 100644 --- a/projects/research/pi-admission/middleware/Cargo.lock +++ b/projects/research/pi-admission/middleware/Cargo.lock @@ -132,6 +132,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -202,6 +208,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -263,7 +281,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] @@ -297,6 +331,27 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -319,6 +374,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -415,6 +480,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -439,6 +505,17 @@ dependencies = [ "r-efi", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.4.19" @@ -479,6 +556,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.5.0" @@ -622,22 +717,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "base64", + "ed25519-dalek", "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", "pem", + "rand", + "rsa", "serde", "serde_json", + "sha2", "signature", "simple_asn1", "zeroize", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -695,6 +812,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -710,6 +843,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -717,6 +860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -725,6 +869,30 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "pem" version = "3.0.6" @@ -814,6 +982,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -849,6 +1028,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1014,6 +1202,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1028,6 +1226,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1097,6 +1315,20 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1202,6 +1434,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core", ] @@ -1239,6 +1472,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spki" version = "0.7.3" diff --git a/projects/research/pi-admission/middleware/Cargo.toml b/projects/research/pi-admission/middleware/Cargo.toml index b7f83492..e1c9472e 100644 --- a/projects/research/pi-admission/middleware/Cargo.toml +++ b/projects/research/pi-admission/middleware/Cargo.toml @@ -16,7 +16,7 @@ base64 = "0.22" bytes = "1" ed25519-dalek = { version = "2", features = ["pem", "pkcs8", "rand_core"] } futures-core = "0.3" -jsonwebtoken = "10" +jsonwebtoken = { version = "10", features = ["rust_crypto"] } prost = "0.14" prost-types = "0.14" rand = "0.8" diff --git a/projects/research/pi-admission/middleware/src/auth.rs b/projects/research/pi-admission/middleware/src/auth.rs index 3bf1b38e..60becf97 100644 --- a/projects/research/pi-admission/middleware/src/auth.rs +++ b/projects/research/pi-admission/middleware/src/auth.rs @@ -63,3 +63,47 @@ impl GatewayAuthentication { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{SigningKey, pkcs8::EncodePrivateKey}; + use jsonwebtoken::{EncodingKey, Header, encode}; + use rand::rngs::OsRng; + use serde_json::json; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn gateway_token_authenticates_and_binds_caller() { + let key = SigningKey::generate(&mut OsRng); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&["gateway"]); + validation.set_audience(&["pi-admission"]); + let auth = GatewayAuthentication { + key: DecodingKey::from_ed_der(key.verifying_key().as_bytes()), + validation, + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let claims = json!({"iss": "gateway", "aud": "pi-admission", "iat": now, + "exp": now + 60, "caller_kind": "supervisor", "sandbox_id": "sandbox"}); + let mut header = Header::new(Algorithm::EdDSA); + header.typ = Some("openshell-ext+jwt".into()); + let token = encode( + &header, + &claims, + &EncodingKey::from_ed_der(key.to_pkcs8_der().unwrap().as_bytes()), + ) + .unwrap(); + let mut metadata = MetadataMap::new(); + metadata.insert("authorization", format!("Bearer {token}").parse().unwrap()); + assert!( + auth.verify(&metadata, "supervisor", Some("sandbox")) + .is_ok() + ); + assert!(auth.verify(&metadata, "supervisor", Some("other")).is_err()); + assert!(auth.verify(&metadata, "gateway", None).is_err()); + } +} From 6918f58a773f79ce191b8c17d056835da6431494 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 18 Sep 2026 15:17:16 +0000 Subject: [PATCH 05/14] Allow supervisor manifest discovery during sandbox startup --- .../pi-admission/middleware/src/auth.rs | 17 ++++++++++++----- .../research/pi-admission/middleware/src/lib.rs | 7 ++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/projects/research/pi-admission/middleware/src/auth.rs b/projects/research/pi-admission/middleware/src/auth.rs index 60becf97..eebfc709 100644 --- a/projects/research/pi-admission/middleware/src/auth.rs +++ b/projects/research/pi-admission/middleware/src/auth.rs @@ -33,7 +33,7 @@ impl GatewayAuthentication { pub(crate) fn verify( &self, metadata: &MetadataMap, - expected_kind: &str, + expected_kinds: &[&str], sandbox_id: Option<&str>, ) -> Result<(), Status> { let values: Vec<_> = metadata.get_all("authorization").iter().collect(); @@ -55,7 +55,7 @@ impl GatewayAuthentication { let claims = decode::(token, &self.key, &self.validation) .map_err(|_| Status::unauthenticated("authentication failed"))? .claims; - if claims.caller_kind != expected_kind + if !expected_kinds.contains(&claims.caller_kind.as_str()) || sandbox_id.is_some_and(|expected| claims.sandbox_id.as_deref() != Some(expected)) { return Err(Status::permission_denied("caller context mismatch")); @@ -100,10 +100,17 @@ mod tests { let mut metadata = MetadataMap::new(); metadata.insert("authorization", format!("Bearer {token}").parse().unwrap()); assert!( - auth.verify(&metadata, "supervisor", Some("sandbox")) + auth.verify(&metadata, &["supervisor"], Some("sandbox")) + .is_ok() + ); + assert!( + auth.verify(&metadata, &["supervisor"], Some("other")) + .is_err() + ); + assert!(auth.verify(&metadata, &["gateway"], None).is_err()); + assert!( + auth.verify(&metadata, &["gateway", "supervisor"], None) .is_ok() ); - assert!(auth.verify(&metadata, "supervisor", Some("other")).is_err()); - assert!(auth.verify(&metadata, "gateway", None).is_err()); } } diff --git a/projects/research/pi-admission/middleware/src/lib.rs b/projects/research/pi-admission/middleware/src/lib.rs index 587cc9f3..49a00673 100644 --- a/projects/research/pi-admission/middleware/src/lib.rs +++ b/projects/research/pi-admission/middleware/src/lib.rs @@ -98,8 +98,9 @@ impl SupervisorMiddleware for Middleware { &self, request: Request<()>, ) -> Result, Status> { + // Both gateway registration and sandbox startup discover the manifest. self.authentication - .verify(request.metadata(), "gateway", None)?; + .verify(request.metadata(), &["gateway", "supervisor"], None)?; Ok(Response::new(self.manifest())) } @@ -108,7 +109,7 @@ impl SupervisorMiddleware for Middleware { request: Request, ) -> Result, Status> { self.authentication - .verify(request.metadata(), "gateway", None)?; + .verify(request.metadata(), &["gateway"], None)?; let body = request.into_inner(); let valid = body.middleware_name == self.config.middleware_name && body @@ -136,7 +137,7 @@ impl SupervisorMiddleware for Middleware { .map(|context| context.sandbox_id.as_str()) .filter(|value| !value.is_empty()); self.authentication - .verify(request.metadata(), "supervisor", sandbox_id)?; + .verify(request.metadata(), &["supervisor"], sandbox_id)?; let request = request.into_inner(); if request.phase != pb::SupervisorMiddlewarePhase::PreCredentials as i32 { return Ok(Response::new(Self::deny("unsupported_phase"))); From 95f90a14a4ab8b756f2d3132642e7f5d70ba346f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 18 Sep 2026 15:20:04 +0000 Subject: [PATCH 06/14] Include OpenShell sandbox networking tools and clarify demo operation --- projects/research/pi-admission/README.md | 26 ++++++++++++++++--- .../research/pi-admission/sandbox/Dockerfile | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/projects/research/pi-admission/README.md b/projects/research/pi-admission/README.md index 249d8248..5c4d1dce 100644 --- a/projects/research/pi-admission/README.md +++ b/projects/research/pi-admission/README.md @@ -1,8 +1,8 @@ # Minimal Pi admission spike This research spike shows why content policy must integrate at the agent -harness—not only at network egress. It runs an unmodified Pi coding session in -OpenShell with two boundaries: +harness—not only at network egress. It uses unmodified Pi libraries for a +controlled coding session in OpenShell with two boundaries: ```text draft -> admission HTTPS -> Pi history and JSONL -> provider request @@ -68,11 +68,17 @@ Start the service: ./demo.sh serve ``` -Then print and install the middleware registration in the gateway's -operator-owned configuration before creating the sandbox: +Then print the middleware registration: ```sh ./demo.sh registration +``` + +Add that entry to the gateway's operator-owned configuration and restart the +gateway while `serve` is running. Both the gateway and each sandbox connect to +the service at startup. Then create the demo sandbox: + +```sh ./demo.sh setup ./demo.sh launch ``` @@ -94,9 +100,21 @@ secrets, for example `./demo.sh --print setup`. Run the paid live check with compaction, saved JSONL, and rejection of a provider request without a receipt. It requires the running gateway, sandbox, service, and model credential. +Inspect network decisions using OpenShell's existing logs (replace `YOUR_GATEWAY` +with the gateway from `.env`): + +```sh +openshell --gateway YOUR_GATEWAY logs pi-admission --source sandbox --since 5m +``` + +The verification's bypass attempt should report `receipt_missing`. User input +denied before any model request stays inside the harness boundary; it is not a +network request and will not appear as an egress denial. + Finish with `./demo.sh cleanup`. It removes only the example sandbox, sessions, providers, and profiles. It retains host configuration, gateway registration, and the Docker image. +Run cleanup before repeating setup after a failed or completed demo. ## Development diff --git a/projects/research/pi-admission/sandbox/Dockerfile b/projects/research/pi-admission/sandbox/Dockerfile index 6df2f878..ec084af0 100644 --- a/projects/research/pi-admission/sandbox/Dockerfile +++ b/projects/research/pi-admission/sandbox/Dockerfile @@ -4,7 +4,7 @@ FROM node:22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates fd-find ripgrep \ + && apt-get install -y --no-install-recommends ca-certificates fd-find ripgrep iproute2 nftables \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ && useradd --create-home --uid 1001 sandbox \ && rm -rf /var/lib/apt/lists/* From 29ea42688c0fa673c1b73a2e93a6a22c9c225337 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 18 Sep 2026 19:02:27 +0000 Subject: [PATCH 07/14] Clarify portable Pi admission demo setup and cleanup --- projects/research/pi-admission/README.md | 96 +++++++++++++++++++----- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/projects/research/pi-admission/README.md b/projects/research/pi-admission/README.md index 5c4d1dce..1ddcf7be 100644 --- a/projects/research/pi-admission/README.md +++ b/projects/research/pi-admission/README.md @@ -46,37 +46,83 @@ inspect, edit, and test real code without extra project scaffolding. ## Run -Prerequisites are an existing HTTPS/mTLS OpenShell gateway, OpenShell `0.0.116` -or a compatible release, Bash, Python 3.11+, uv 0.11+, Rust 1.90+, Docker, Node -22 for local development, and one OpenAI-compatible Chat Completions key. +You need an existing HTTPS/mTLS OpenShell gateway registered in your CLI, access +to its configuration and restart procedure, Bash, Python 3.11+, uv 0.11+, +Rust 1.90+ with native build tools, and Docker. OpenShell `0.0.116` was tested; +other releases must support the same middleware contract. Node 22 is needed +only for local harness development; the demo image includes it. + +Use your own model provider: it must support HTTPS, streaming OpenAI-compatible +Chat Completions, text input, tool calling, and API-key authentication. This +spike does not support OAuth, custom authentication headers, or every Pi API. +Model calls, including the verification, may incur provider charges. + +### 1. Configure and prepare + +Run these commands from `projects/research/pi-admission/`: ```sh cp .env.example .env cp models.json.example models.json -# Fill in the three values documented in .env. +``` + +Edit `models.json` to describe your provider and model using Pi's native catalog +format. Set the provider name, `baseUrl`, model `id`, limits, and compatibility +settings for your endpoint; keep `api: "openai-completions"` and text-only input. +The supplied OpenRouter/GLM configuration is an example, not a requirement. +Do not put API keys in this file: it is copied into the sandbox image. + +Set these values in `.env`: + +| Variable | Your value | +| --- | --- | +| `OPENSHELL_GATEWAY` | The gateway name shown by `openshell gateway list` | +| `PI_ADMISSION_HOST` | DNS hostname or IPv4 address of the machine running `serve`, without a scheme or port | +| `PI_MODEL_API_KEY` | The API key for the selected model provider | +| `PI_MODEL` | Only when the catalog has multiple models: `provider/model-id` | + +The gateway and sandbox supervisor must reach the service on TCP **50051**; +Pi inside the sandbox uses TCP **5443**. Choose a hostname/address reachable +from both gateway and sandbox; `localhost` +inside a sandbox points to the sandbox, not your host. Docker-specific hostnames +are suitable only if they also resolve from the gateway. Allow these connections +through the host firewall. + +```sh ./demo.sh prepare ``` -`PI_ADMISSION_HOST` must be reachable from the gateway and sandbox. Preparation -discovers the selected gateway identity over its existing mTLS connection, -creates a 30-day local service certificate, and writes private state under -`.workspaces/`. +Preparation discovers the gateway identity using your CLI's existing mTLS +credentials, creates a 30-day service certificate, and builds the local +`pi-admission:local` image. Private generated state stays in `.workspaces/`. + +### 2. Start and register the service -Start the service: +In one terminal, start the service and leave it running: ```sh ./demo.sh serve ``` -Then print the middleware registration: +In a second terminal, from the same project directory, print the registration: ```sh ./demo.sh registration ``` -Add that entry to the gateway's operator-owned configuration and restart the -gateway while `serve` is running. Both the gateway and each sandbox connect to -the service at startup. Then create the demo sandbox: +This command only prints TOML; it does not register anything. Add the entry to +the configuration loaded by your gateway. If the gateway runs on another machine +or in a container, copy/mount the **public** `.workspaces/tls/ca.crt` there and +adjust `tls_ca_cert_path` to a path readable by the gateway process. Do not copy +the service's private key. + +Restart the gateway using the procedure for your installation, while `serve` +is running. Both the gateway and each sandbox connect to the service at startup. +This restart may briefly affect other gateway users. + +### 3. Launch and try it + +In the second terminal: ```sh ./demo.sh setup @@ -94,6 +140,14 @@ Repeat alice@example.com. /quit ``` +The email should appear as `[EMAIL]` in the admitted conversation. The synthetic +SSN-shaped input should be denied without entering live history or JSONL. Tool +edits should change the sample and its test. `/compact` may report insufficient +history in a short session; continue working or use the verification below, +which deliberately exercises compaction with a smaller retention threshold. + +### 4. Verify and inspect egress + Every action has a side-effect-free form that neither sources `.env` nor reveals secrets, for example `./demo.sh --print setup`. Run the paid live check with `./demo.sh verify`; it checks denial, redaction, a real write tool, manual @@ -111,10 +165,18 @@ The verification's bypass attempt should report `receipt_missing`. User input denied before any model request stays inside the harness boundary; it is not a network request and will not appear as an egress denial. -Finish with `./demo.sh cleanup`. It removes only the example sandbox, sessions, -providers, and profiles. It retains host configuration, gateway registration, -and the Docker image. -Run cleanup before repeating setup after a failed or completed demo. +### 5. Clean up + +After exiting Pi, run `./demo.sh cleanup`. It deletes the example sandbox and its +sessions, providers, and profiles; save any work you want to retain first. It +keeps host configuration, gateway registration, and the Docker image. Run cleanup +before repeating setup after a failed or completed demo. The scripts use fixed +`pi-admission` resource names; use a gateway where those names are available. + +When finished permanently, remove the `pi-admission` middleware entry from the +gateway configuration and restart the gateway **before stopping `serve`**. +Otherwise a later gateway startup may fail while trying to contact the stopped +service. Finally, stop `serve` with Ctrl-C. ## Development From 63808b8f36a66106a3c95b1f59c394e44c11d8e0 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 18 Sep 2026 19:53:14 +0000 Subject: [PATCH 08/14] Document middleware registration and network-only comparison --- projects/research/pi-admission/README.md | 97 ++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/projects/research/pi-admission/README.md b/projects/research/pi-admission/README.md index 1ddcf7be..31540015 100644 --- a/projects/research/pi-admission/README.md +++ b/projects/research/pi-admission/README.md @@ -110,16 +110,50 @@ In a second terminal, from the same project directory, print the registration: ./demo.sh registration ``` -This command only prints TOML; it does not register anything. Add the entry to -the configuration loaded by your gateway. If the gateway runs on another machine -or in a container, copy/mount the **public** `.workspaces/tls/ca.crt` there and -adjust `tls_ca_cert_path` to a path readable by the gateway process. Do not copy -the service's private key. +This command reads `.workspaces/middleware.toml`, created by `prepare`, and +prints it. It does **not** write to the gateway configuration, register the +middleware, or restart anything. + +Find the TOML configuration actually loaded by your gateway process. Its path +depends on how the gateway was installed: check its service/startup configuration +or ask its operator. This is the **server's configuration**, not the CLI's local +gateway credentials. Do not assume a file named `gateway.toml` in the current +directory is the right one. + +Back up that file, then add the printed `[[openshell.supervisor.middleware]]` +entry. For a gateway configuration accessible on this machine, the command is: + +```sh +# Replace this placeholder with the existing, active gateway config path. +./demo.sh registration >> /path/to/gateway.toml +``` + +If the file requires administrator permissions, use this **instead**: + +```sh +./demo.sh registration | sudo tee -a /path/to/gateway.toml >/dev/null +``` + +Both commands **append**. Run only one, and only when no middleware entry named +`pi-admission` already exists. On subsequent runs, edit the existing entry rather +than appending a duplicate. Do not use `>`: it would overwrite the gateway's +other settings. + +For a remote or containerized gateway, install the entry in that deployment's +configuration instead of appending to an unrelated local file. Copy/mount the +**public** `.workspaces/tls/ca.crt` there and adjust `tls_ca_cert_path` in the entry +to a path readable by the gateway process. Do not copy the service's private key. Restart the gateway using the procedure for your installation, while `serve` is running. Both the gateway and each sandbox connect to the service at startup. This restart may briefly affect other gateway users. +Before continuing, check that it is healthy (use your `.env` gateway name): + +```sh +openshell --gateway YOUR_GATEWAY gateway info +``` + ### 3. Launch and try it In the second terminal: @@ -178,6 +212,59 @@ gateway configuration and restart the gateway **before stopping `serve`**. Otherwise a later gateway startup may fail while trying to contact the stopped service. Finally, stop `serve` with Ctrl-C. +## Comparison: network redaction alone + +**What this demonstrates:** a proxy can sanitize what the model receives without +sanitizing the agent's live conversation or saved session. Those are different +boundaries; successful network redaction is not evidence of clean local history. + +Use a separate sandbox running ordinary Pi, without `pi-harness`, for this +comparison. Keep its normal model-provider and network configuration, but attach +OpenShell's built-in regex middleware to the model endpoint instead of the +`pi-admission` middleware: + +```yaml +network_middlewares: + network_redaction: + middleware: openshell/regex + config: + mode: redact + on_error: fail_closed + endpoints: + include: ["YOUR_MODEL_PROVIDER_HOST"] +``` + +This is a policy fragment, not a complete sandbox policy. The built-in middleware +needs no external service registration. In OpenShell `0.0.116`, it replaces +`sk-[A-Za-z0-9_-]{16,}` with `[REDACTED]`; its patterns are fixed, and it does not +recognize this spike's email/SSN patterns. + +In ordinary Pi, send this **fake token**, never a real credential: + +```text +Repeat this demonstration token exactly: sk-DEMO_ONLY_NOT_A_REAL_KEY_123456 +``` + +Inspect OpenShell's sandbox logs for the regex transformation, then inspect Pi's +original user message and search its saved session JSONL for the fake token. +The original remains in local history even though the outgoing request was +redacted. The model's reply may show `[REDACTED]`, but do not rely on model +obedience alone as proof of what crossed the network. + +Compare that with `alice@example.com` in our admission demo: + +| Approach | Outgoing content | Pi history and JSONL | +| --- | --- | --- | +| Ordinary Pi + network-only regex | Fake token redacted | Original fake token remains | +| Admission harness | Email redacted | Only `[EMAIL]` is published | + +The two examples deliberately use different fixed patterns. For an identical +input comparison, the Rust admission policy would need the same fake-token +pattern; it does not currently contain it. Do not layer this network replacement +onto the receipt-enforced demo: changing attested content can invalidate the +receipt and obscure the comparison. `./demo.sh launch` always starts the admission +harness, not the ordinary-Pi baseline. + ## Development ```sh From b8b41b4959b674e53c27228209779e0fce1d12b3 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 18 Sep 2026 19:53:14 +0000 Subject: [PATCH 09/14] Include Python in the Pi admission sandbox image --- projects/research/pi-admission/sandbox/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/research/pi-admission/sandbox/Dockerfile b/projects/research/pi-admission/sandbox/Dockerfile index ec084af0..099e980c 100644 --- a/projects/research/pi-admission/sandbox/Dockerfile +++ b/projects/research/pi-admission/sandbox/Dockerfile @@ -4,7 +4,7 @@ FROM node:22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates fd-find ripgrep iproute2 nftables \ + && apt-get install -y --no-install-recommends ca-certificates fd-find ripgrep iproute2 nftables python3 \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ && useradd --create-home --uid 1001 sandbox \ && rm -rf /var/lib/apt/lists/* From 2f887222547fda3036a7abacf14d37a7b4325330 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 21 Sep 2026 14:40:48 +0000 Subject: [PATCH 10/14] refactor(pi-admission): apply admission locally --- .../pi-admission/pi-harness/src/admission.ts | 332 ++++-------------- .../pi-admission/pi-harness/src/agent.ts | 32 +- .../pi-admission/pi-harness/src/cli.ts | 34 +- .../pi-admission/pi-harness/src/config.ts | 60 ++++ .../pi-admission/pi-harness/src/model.ts | 31 -- .../pi-admission/pi-harness/src/network.ts | 11 - .../pi-admission/pi-harness/src/session.ts | 216 +++++------- .../pi-admission/pi-harness/src/verify.ts | 84 ++--- .../pi-admission/pi-harness/test/e2e.test.ts | 266 ++++++++------ 9 files changed, 419 insertions(+), 647 deletions(-) create mode 100644 projects/research/pi-admission/pi-harness/src/config.ts delete mode 100644 projects/research/pi-admission/pi-harness/src/model.ts delete mode 100644 projects/research/pi-admission/pi-harness/src/network.ts diff --git a/projects/research/pi-admission/pi-harness/src/admission.ts b/projects/research/pi-admission/pi-harness/src/admission.ts index e164bf07..8a01dff4 100644 --- a/projects/research/pi-admission/pi-harness/src/admission.ts +++ b/projects/research/pi-admission/pi-harness/src/admission.ts @@ -1,295 +1,107 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { randomUUID } from "node:crypto"; -import { isDeepStrictEqual } from "node:util"; -import type { - Context, - Message, - TextContent, -} from "@earendil-works/pi-ai"; - -export const RECEIPT_HEADER = "x-pi-admission-receipt"; -export const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; +import type { Message, TextContent } from "@earendil-works/pi-ai"; +export type AdmissionMode = "off" | "on"; export type TextOrigin = "user" | "compaction_summary"; -export type AdmissionKind = - | "user_message" - | "compaction_summary" - | "assistant_message" - | "tool_result" - | "provider_context"; -export type AdmissionResponse = { - decision: "allow" | "replace" | "deny"; - replacement: Record | null; - receipt: string | null; -}; -export type Evaluate = ( - kind: AdmissionKind, - body: Record, - signal?: AbortSignal, -) => Promise; + +const SYNTHETIC_KEY = /sk-[A-Za-z0-9_-]{16,}/g; +const REPLACEMENT = "[REDACTED]"; +const EDITABLE_THINKING_SIGNATURES = new Set([ + "reasoning", + "reasoning_content", + "reasoning_text", +]); export class AdmissionError extends Error { - constructor( - readonly kind: "denied" | "unavailable" | "unsupported" | "invalid", - ) { + constructor(readonly kind: "unsupported" | "invalid") { super( - { - denied: - "Admission denied this content; the candidate was not added to history.", - unavailable: - "Admission is unavailable; no unchecked content will be added.", - unsupported: - "This content is outside the example’s supported Chat Completions format.", - invalid: - "Admission returned an inconsistent result; the operation was stopped.", - }[kind], + kind === "unsupported" + ? "This content is outside the example’s supported Chat Completions format." + : "Local admission cannot safely transform signed content or tool-call semantics.", ); } } -export function createHttpEvaluator( - url: string, - credential: string, - sessionId: string, -): Evaluate { - if (new URL(url).protocol !== "https:") - throw new Error("Admission requires HTTPS."); - return async (kind, body, signal) => { - const encoded = JSON.stringify({ - kind, - body, - session_id: sessionId, - submission_id: randomUUID(), - }); - if (Buffer.byteLength(encoded) > MAX_ADMISSION_BYTES) - throw new AdmissionError("unsupported"); - try { - const response = await fetch(url, { - method: "POST", - headers: { - authorization: `Bearer ${credential}`, - "content-type": "application/json", - }, - body: encoded, - signal: AbortSignal.any([ - AbortSignal.timeout(30_000), - ...(signal ? [signal] : []), - ]), - }); - if (!response.ok) throw new AdmissionError("unavailable"); - const encodedResult = await response.text(); - if (Buffer.byteLength(encodedResult) > MAX_ADMISSION_BYTES + 16_384) - throw new AdmissionError("invalid"); - const result: unknown = JSON.parse(encodedResult); - if ( - !isRecord(result) || - !["allow", "replace", "deny"].includes(String(result.decision)) - ) - throw new AdmissionError("invalid"); - if (result.decision === "deny") - return { decision: "deny", replacement: null, receipt: null }; - if (result.decision === "allow" && result.replacement !== null) - throw new AdmissionError("invalid"); - if (result.decision === "replace" && !isRecord(result.replacement)) - throw new AdmissionError("invalid"); - if (kind === "provider_context") { - if ( - typeof result.receipt !== "string" || - !/^[A-Za-z0-9_-]+={0,2}$/.test(result.receipt) || - result.receipt.length > 11_000 - ) - throw new AdmissionError("invalid"); - } else if (result.receipt !== null) throw new AdmissionError("invalid"); - return { - decision: result.decision as "allow" | "replace", - replacement: result.replacement as Record | null, - receipt: result.receipt as string | null, - }; - } catch (error) { - if (error instanceof AdmissionError) throw error; - throw new AdmissionError("unavailable"); - } - }; -} - +/** Apply the example's one local policy directly to native Pi messages. */ export class Admission { - constructor(private readonly evaluate: Evaluate) {} + constructor(readonly mode: AdmissionMode) {} async text( - origin: TextOrigin, + _origin: TextOrigin, text: string, signal?: AbortSignal, ): Promise { - const kind = { - user: "user_message", - compaction_summary: "compaction_summary", - } as const; - const envelope = { - schema_version: "openshell.pi-message.v1", - origin, - text, - }; - const admitted = await this.apply(kind[origin], envelope, signal); - if ( - admitted.origin !== origin || - admitted.schema_version !== envelope.schema_version || - typeof admitted.text !== "string" - ) - throw new AdmissionError("invalid"); - return admitted.text; + signal?.throwIfAborted(); + return this.redact(text); } async message(message: Message, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); if (message.role === "user") { - const original = textOnly(message.content); - const approved = await this.text("user", original, signal); - if (approved === original) return message; - if (typeof message.content === "string") - return { ...message, content: approved }; - if (message.content.length !== 1 || message.content[0].type !== "text" || - message.content[0].textSignature) - throw new AdmissionError("invalid"); - return { ...message, content: [{ ...message.content[0], text: approved }] }; + if (typeof message.content === "string") { + const content = this.redact(message.content); + return content === message.content ? message : { ...message, content }; + } + return { + ...message, + content: message.content.map((block) => { + if (block.type !== "text") throw new AdmissionError("unsupported"); + const text = this.redact(block.text); + if (text !== block.text && block.textSignature) + throw new AdmissionError("invalid"); + return text === block.text ? block : { ...block, text }; + }), + }; } if (message.role === "assistant") { - if (message.content.some((block) => - block.type !== "text" && block.type !== "toolCall" && block.type !== "thinking")) - throw new AdmissionError("unsupported"); - const texts = message.content.filter((block) => block.type === "text"); - const thinking = message.content.filter((block) => block.type === "thinking"); - const calls = message.content.filter((block) => block.type === "toolCall").map((call) => ({ - id: call.id, name: call.name, arguments: call.arguments, - thought_signature: call.thoughtSignature ?? null, - })); - const envelope = { - schema_version: "openshell.pi-assistant-message.v1", - text: texts.map((block) => block.text).join("\n"), - tool_calls: calls, - thinking: thinking.map((block) => ({ - text: block.thinking, signature: block.thinkingSignature ?? null, - })), - }; - const admitted = await this.apply("assistant_message", envelope, signal); - // Preserve the complete native message on allow, including block order, - // signatures, usage and provider metadata. - if (admitted === envelope) return message; - if (typeof admitted.text !== "string" || - !isDeepStrictEqual(admitted.tool_calls, calls) || - !Array.isArray(admitted.thinking) || admitted.thinking.length !== thinking.length) - throw new AdmissionError("invalid"); - const changedText = admitted.text !== envelope.text; - // A joined text projection cannot safely identify edits across multiple blocks. - if (changedText && (texts.length !== 1 || texts[0].textSignature)) - throw new AdmissionError("invalid"); - const replacements = admitted.thinking.map((value: unknown, index: number) => { - const original = envelope.thinking[index]; - if (!isRecord(value) || typeof value.text !== "string" || - value.signature !== original.signature || - (original.signature !== null && - !["reasoning", "reasoning_content", "reasoning_text"].includes(original.signature) && - value.text !== original.text)) - throw new AdmissionError("invalid"); - return value.text; - }); - let index = 0; return { ...message, content: message.content.map((block) => { - if (block.type === "text" && changedText) - return { ...block, text: admitted.text as string }; - if (block.type === "thinking") - return { ...block, thinking: replacements[index++] }; - return block; + if (block.type === "toolCall") { + if ( + this.mode === "on" && + JSON.stringify(block).match(SYNTHETIC_KEY) + ) + throw new AdmissionError("invalid"); + return block; + } + if (block.type === "text") { + const text = this.redact(block.text); + if (text !== block.text && block.textSignature) + throw new AdmissionError("invalid"); + return text === block.text ? block : { ...block, text }; + } + if (block.type === "thinking") { + const thinking = this.redact(block.thinking); + if ( + thinking !== block.thinking && + block.thinkingSignature && + !EDITABLE_THINKING_SIGNATURES.has(block.thinkingSignature) + ) + throw new AdmissionError("invalid"); + return thinking === block.thinking + ? block + : { ...block, thinking }; + } + throw new AdmissionError("unsupported"); }), }; } - // Keep text block boundaries and metadata; images remain outside this POC. - textOnly(message.content); - const envelope = { - schema_version: "openshell.pi-tool-result.v1", - tool_call_id: message.toolCallId, - tool_name: message.toolName, - content: message.content.map((block) => ({ type: "text", text: (block as TextContent).text })), - is_error: message.isError, + return { + ...message, + content: message.content.map((block): TextContent => { + if (block.type !== "text") throw new AdmissionError("unsupported"); + const text = this.redact(block.text); + if (text !== block.text && block.textSignature) + throw new AdmissionError("invalid"); + return text === block.text ? block : { ...block, text }; + }), }; - const admitted = await this.apply("tool_result", envelope, signal); - if (admitted === envelope) return message; - if (admitted.tool_call_id !== message.toolCallId || - admitted.tool_name !== message.toolName || admitted.is_error !== message.isError || - !Array.isArray(admitted.content) || admitted.content.length !== message.content.length) - throw new AdmissionError("invalid"); - const content = admitted.content.map((value: unknown, index: number): TextContent => { - const original = message.content[index] as TextContent; - if (!isRecord(value) || value.type !== "text" || typeof value.text !== "string" || - (original.textSignature && value.text !== original.text)) - throw new AdmissionError("invalid"); - return { ...original, text: value.text }; - }); - return { ...message, content }; } - async receipt(context: Context, signal?: AbortSignal): Promise { - const entries = context.messages.flatMap((message) => { - if (message.role === "user") - return [{ role: "user", text: textOnly(message.content) }]; - if (message.role === "toolResult") - return [ - { - role: "tool", - tool_call_id: message.toolCallId.split("|", 1)[0], - text: textOnly(message.content) || "(no tool output)", - }, - ]; - return []; - }); - const result = await this.evaluate( - "provider_context", - { schema_version: "openshell.pi-provider-context.v1", entries }, - signal, - ); - if (result.decision === "deny") throw new AdmissionError("denied"); - // A send-only replacement would leave saved history inconsistent. Fix the - // earlier admission boundary instead of silently diverging at egress. - if (result.decision !== "allow" || !result.receipt) - throw new AdmissionError("invalid"); - return result.receipt; + private redact(text: string): string { + return this.mode === "on" ? text.replace(SYNTHETIC_KEY, REPLACEMENT) : text; } - - private async apply( - kind: AdmissionKind, - body: Record, - signal?: AbortSignal, - ): Promise> { - const result = await this.evaluate(kind, body, signal); - if (signal?.aborted) throw new AdmissionError("unavailable"); - if (result.decision === "deny") throw new AdmissionError("denied"); - if (result.decision === "replace") { - if ( - !result.replacement || - result.replacement.schema_version !== body.schema_version - ) - throw new AdmissionError("invalid"); - return result.replacement; - } - return body; - } -} - -export function textOnly( - content: string | readonly { type: string; text?: string }[], -): string { - if (typeof content === "string") return content; - if ( - content.some( - (block) => block.type !== "text" || typeof block.text !== "string", - ) - ) - throw new AdmissionError("unsupported"); - return content.map((block) => block.text).join("\n"); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/projects/research/pi-admission/pi-harness/src/agent.ts b/projects/research/pi-admission/pi-harness/src/agent.ts index 7122130d..3f083d89 100644 --- a/projects/research/pi-admission/pi-harness/src/agent.ts +++ b/projects/research/pi-admission/pi-harness/src/agent.ts @@ -28,7 +28,7 @@ export class ContextOverflowError extends Error {} * AgentSession alone persists the approved message_end events. */ export class AdmissionAgent extends Agent { - private readonly live; + private readonly live = { ...super.state, pendingToolCalls: new Set() }; private readonly subscribers = new Set< (event: AgentEvent, signal: AbortSignal) => Promise | void >(); @@ -46,11 +46,6 @@ export class AdmissionAgent extends Agent { initialState: { model, thinkingLevel: clampThinkingLevel(model, "medium") }, streamFn, }); - // Pi's session persists only the approved events emitted by this loop. - this.live = { - ...super.state, - pendingToolCalls: new Set(), - }; } override get state() { @@ -73,20 +68,12 @@ export class AdmissionAgent extends Agent { override waitForIdle() { return this.settled; } - override steer(message: AgentMessage) { - void message; + override steer() { throw new Error("Agent is busy; wait or cancel the active turn."); } - override followUp(message: AgentMessage) { - void message; + override followUp() { throw new Error("Agent is busy; wait or cancel the active turn."); } - override clearSteeringQueue() {} - override clearFollowUpQueue() {} - override clearAllQueues() {} - override hasQueuedMessages() { - return false; - } override reset() { if (this.live.isStreaming) throw new Error("Cancel the current operation first."); @@ -189,22 +176,15 @@ export class AdmissionAgent extends Agent { : prepared.arguments) as typeof prepared.arguments; args = validateToolArguments(tool, prepared); if (!isDeepStrictEqual(args, call.arguments)) { - const checked = (await this.admit({ + // Admission.message already rejects changes to tool calls. + await this.admit({ ...assistant, content: assistant.content.map((block) => block.type === "toolCall" && block.id === call.id ? { ...block, arguments: args as typeof block.arguments } : block, ), - })) as AssistantMessage; - const checkedCall = checked.content.find( - (block) => block.type === "toolCall" && block.id === call.id, - ); - if ( - checkedCall?.type !== "toolCall" || - !isDeepStrictEqual(checkedCall.arguments, args) - ) - throw new AdmissionError("invalid"); + }); } // No onUpdate callback: partial tool output is not approved yet. result = await tool.execute(call.id, args, this.signal); diff --git a/projects/research/pi-admission/pi-harness/src/cli.ts b/projects/research/pi-admission/pi-harness/src/cli.ts index 90de2e97..42f780bd 100644 --- a/projects/research/pi-admission/pi-harness/src/cli.ts +++ b/projects/research/pi-admission/pi-harness/src/cli.ts @@ -1,38 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { randomUUID } from "node:crypto"; -import { parseArgs } from "node:util"; import { InteractiveMode } from "@earendil-works/pi-coding-agent"; -import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; +import { AdmissionError } from "./admission.js"; import { createAdmissionRuntime } from "./session.js"; -import { configureProxy } from "./network.js"; -import { loadSelectedModel } from "./model.js"; +import { loadSessionOptions } from "./config.js"; async function main(): Promise { - configureProxy(); - const { values } = parseArgs({ - options: { - admission: { type: "string" }, - }, - }); - const apiKey = process.env.PI_MODEL_API_KEY; - const admissionKey = process.env.PI_ADMISSION_TOKEN; - delete process.env.PI_MODEL_API_KEY; - delete process.env.PI_ADMISSION_TOKEN; - if (!apiKey || !admissionKey || !values.admission) - throw new Error("Missing provider or admission configuration."); - const model = await loadSelectedModel(); - const runtime = await createAdmissionRuntime({ - cwd: "/sandbox/workspace", - sessionDir: "/sandbox/sessions", - agentDir: "/app/agent", - model, - apiKey, - admission: new Admission( - createHttpEvaluator(values.admission, admissionKey, randomUUID()), - ), - }); + const { mode, session } = await loadSessionOptions(); + const runtime = await createAdmissionRuntime(session); + console.log(`Local admission: ${mode}`); + console.log(`Transcript: ${runtime.session.sessionFile}`); await new InteractiveMode(runtime).run(); } diff --git a/projects/research/pi-admission/pi-harness/src/config.ts b/projects/research/pi-admission/pi-harness/src/config.ts new file mode 100644 index 00000000..e3e5a081 --- /dev/null +++ b/projects/research/pi-admission/pi-harness/src/config.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { + InMemoryCredentialStore, + InMemoryModelsStore, + type Model, +} from "@earendil-works/pi-ai"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; +import { Admission, type AdmissionMode } from "./admission.js"; +import type { SessionOptions } from "./session.js"; + +export interface LoadedSessionOptions { + mode: AdmissionMode; + session: SessionOptions; +} + +/** Shared launcher/verification setup; the credential stays out of tool environments. */ +export async function loadSessionOptions( + directory = "/app", +): Promise { + setGlobalDispatcher(new EnvHttpProxyAgent({ proxyTunnel: true, allowH2: false })); + const { values } = parseArgs({ + options: { admission: { type: "string" } }, + }); + const mode = values.admission; + if (mode !== "off" && mode !== "on") + throw new Error("Pass exactly one of --admission off or --admission on."); + const apiKey = process.env.PI_MODEL_API_KEY; + delete process.env.PI_MODEL_API_KEY; + if (!apiKey) throw new Error("Missing PI_MODEL_API_KEY."); + const { provider, id } = JSON.parse( + await readFile(join(directory, "model-selection.json"), "utf8"), + ) as { provider: string; id: string }; + const runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsStore: new InMemoryModelsStore(), + modelsPath: join(directory, "models.json"), + }); + const error = runtime.getError(); + if (error) throw new Error(error); + const model = runtime.getModel(provider, id); + if (!model || model.api !== "openai-completions") + throw new Error("The prepared model must use openai-completions."); + return { + mode, + session: { + cwd: "/sandbox/workspace", + sessionDir: "/sandbox/sessions", + agentDir: "/app/agent", + model: model as Model<"openai-completions">, + apiKey, + admission: new Admission(mode), + }, + }; +} diff --git a/projects/research/pi-admission/pi-harness/src/model.ts b/projects/research/pi-admission/pi-harness/src/model.ts deleted file mode 100644 index 4d942d19..00000000 --- a/projects/research/pi-admission/pi-harness/src/model.ts +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { - InMemoryCredentialStore, - InMemoryModelsStore, - type Model, -} from "@earendil-works/pi-ai"; -import { ModelRuntime } from "@earendil-works/pi-coding-agent"; - -/** Let Pi resolve its native catalog, without writing into the read-only image. */ -export async function loadSelectedModel( - directory = "/app", -): Promise> { - const { provider, id } = JSON.parse( - await readFile(join(directory, "model-selection.json"), "utf8"), - ) as { provider: string; id: string }; - const runtime = await ModelRuntime.create({ - credentials: new InMemoryCredentialStore(), - modelsStore: new InMemoryModelsStore(), - modelsPath: join(directory, "models.json"), - }); - const error = runtime.getError(); - if (error) throw new Error(error); - const model = runtime.getModel(provider, id); - if (!model || model.api !== "openai-completions") - throw new Error("The prepared model must use openai-completions."); - return model as Model<"openai-completions">; -} diff --git a/projects/research/pi-admission/pi-harness/src/network.ts b/projects/research/pi-admission/pi-harness/src/network.ts deleted file mode 100644 index c45e080c..00000000 --- a/projects/research/pi-admission/pi-harness/src/network.ts +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; - -/** Honor the sandbox's proxy after loading Pi and its HTTP dependencies. */ -export function configureProxy(): void { - setGlobalDispatcher( - new EnvHttpProxyAgent({ proxyTunnel: true, allowH2: false }), - ); -} diff --git a/projects/research/pi-admission/pi-harness/src/session.ts b/projects/research/pi-admission/pi-harness/src/session.ts index 8857c439..8133c6da 100644 --- a/projects/research/pi-admission/pi-harness/src/session.ts +++ b/projects/research/pi-admission/pi-harness/src/session.ts @@ -15,11 +15,10 @@ import { createAgentSessionServices, convertToLlm, compact, - type CreateAgentSessionRuntimeFactory, type PromptOptions, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import { Admission, AdmissionError, RECEIPT_HEADER } from "./admission.js"; +import { Admission, AdmissionError } from "./admission.js"; import { AdmissionAgent, retainedUsage } from "./agent.js"; export interface SessionOptions { @@ -36,14 +35,7 @@ export interface SessionOptions { /** Native Pi session/persistence, with explicit guards for unsupported writes. */ export class AdmissionSession extends AgentSession { static async create(options: SessionOptions): Promise { - const result = await sessionFactory(options)({ - cwd: resolve(options.cwd), - agentDir: resolve(options.agentDir), - sessionManager: SessionManager.create( - resolve(options.cwd), - resolve(options.sessionDir), - ), - }); + const result = await createSession(options); await result.session.bindExtensions({}); return result.session; } @@ -124,24 +116,16 @@ export class AdmissionSession extends AgentSession { export async function createAdmissionRuntime( options: SessionOptions, ): Promise { - const factory = sessionFactory(options); - const result = await factory({ - cwd: resolve(options.cwd), - agentDir: resolve(options.agentDir), - sessionManager: SessionManager.create( - resolve(options.cwd), - resolve(options.sessionDir), - ), - }); + const result = await createSession(options); return new AdmissionRuntime( result.session, result.services, - factory, + async () => unsupported("Session replacement"), result.diagnostics, ); } -function sessionFactory(options: SessionOptions) { +async function createSession(options: SessionOptions) { if ( options.model.api !== "openai-completions" || options.model.input.some((type) => type !== "text") || @@ -157,115 +141,99 @@ function sessionFactory(options: SessionOptions) { }, retry: { enabled: false }, }); - const stream: StreamFn = async (model, context, streamOptions) => { - const receipt = await options.admission.receipt( - context, - streamOptions?.signal, - ); - return (options.stream ?? streamSimple)(model, context, { + const stream: StreamFn = (model, context, streamOptions) => + (options.stream ?? streamSimple)(model, context, { ...streamOptions, apiKey: options.apiKey, - headers: { ...streamOptions?.headers, [RECEIPT_HEADER]: receipt }, }); - }; - return async ({ + const cwd = resolve(options.cwd); + const agentDir = resolve(options.agentDir); + const sessionManager = SessionManager.create(cwd, resolve(options.sessionDir)); + const services = await createAgentSessionServices({ cwd, agentDir, - sessionManager, - sessionStartEvent, - }: Parameters[0]) => { - if (sessionManager.getEntries().length) - return unsupported("Restoring existing history"); - const services = await createAgentSessionServices({ - cwd, - agentDir, - // OpenShell supplies runtime credentials; /app remains read-only. - modelRuntime: await ModelRuntime.create({ - credentials: new InMemoryCredentialStore(), - modelsPath: null, - }), - settingsManager, - resourceLoaderOptions: { - noExtensions: true, - noSkills: true, - noContextFiles: true, - noPromptTemplates: true, - noThemes: true, - extensionFactories: [ - { - name: "admission", - factory: (pi) => { - pi.on("session_before_compact", async (event) => { - // Supplying a summary or explicitly cancelling is mandatory: - // throwing from an extension handler could fall back to Pi's - // unchecked default summarizer. - try { - const summary = await compact( - event.preparation, - options.model, - options.apiKey, - undefined, - event.customInstructions, - event.signal, - session.thinkingLevel, - stream, - undefined, - { enabled: false, maxRetries: 0, baseDelayMs: 0 }, - undefined, - sessionManager.getSessionId(), - ); - const approved = await options.admission.text( - "compaction_summary", - summary.summary, - event.signal, - ); - return { - compaction: { - summary: approved, - firstKeptEntryId: summary.firstKeptEntryId, - tokensBefore: event.preparation.tokensBefore, - ...(summary.usage - ? { usage: retainedUsage(summary.usage) } - : {}), - }, - }; - } catch { - return { cancel: true }; - } - }); - }, + // OpenShell supplies runtime credentials; /app remains read-only. + modelRuntime: await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: null, + }), + settingsManager, + resourceLoaderOptions: { + noExtensions: true, + noSkills: true, + noContextFiles: true, + noPromptTemplates: true, + noThemes: true, + extensionFactories: [ + { + name: "admission", + factory: (pi) => { + pi.on("session_before_compact", async (event) => { + // Supplying a summary or explicitly cancelling is mandatory: + // throwing from an extension handler could fall back to Pi's + // unchecked default summarizer. + try { + const summary = await compact( + event.preparation, + options.model, + options.apiKey, + undefined, + event.customInstructions, + event.signal, + session.thinkingLevel, + stream, + undefined, + { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + undefined, + sessionManager.getSessionId(), + ); + const approved = await options.admission.text( + "compaction_summary", + summary.summary, + event.signal, + ); + return { + compaction: { + summary: approved, + firstKeptEntryId: summary.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + ...(summary.usage + ? { usage: retainedUsage(summary.usage) } + : {}), + }, + }; + } catch { + return { cancel: true }; + } + }); }, - ], - }, - }); - services.modelRuntime.registerProvider(options.model.provider, { - api: options.model.api, - baseUrl: options.model.baseUrl, - models: [options.model], - }); - await services.modelRuntime.setRuntimeApiKey( - options.model.provider, - options.apiKey, - ); - const agent = new AdmissionAgent(options.model, stream, options.admission); - agent.sessionId = sessionManager.getSessionId(); - agent.steeringMode = settingsManager.getSteeringMode(); - agent.followUpMode = settingsManager.getFollowUpMode(); - const session = new AdmissionSession({ - agent, - cwd, - sessionManager, - sessionStartEvent, - settingsManager: services.settingsManager, - resourceLoader: services.resourceLoader, - modelRuntime: services.modelRuntime, - }); - return { - session, - services, - diagnostics: services.diagnostics, - extensionsResult: services.resourceLoader.getExtensions(), - }; + }, + ], + }, + }); + services.modelRuntime.registerProvider(options.model.provider, { + api: options.model.api, + baseUrl: options.model.baseUrl, + models: [options.model], + }); + await services.modelRuntime.setRuntimeApiKey( + options.model.provider, + options.apiKey, + ); + const agent = new AdmissionAgent(options.model, stream, options.admission); + agent.sessionId = sessionManager.getSessionId(); + const session = new AdmissionSession({ + agent, + cwd, + sessionManager, + settingsManager: services.settingsManager, + resourceLoader: services.resourceLoader, + modelRuntime: services.modelRuntime, + }); + return { + session, + services, + diagnostics: services.diagnostics, }; } diff --git a/projects/research/pi-admission/pi-harness/src/verify.ts b/projects/research/pi-admission/pi-harness/src/verify.ts index c3e55017..b347537f 100644 --- a/projects/research/pi-admission/pi-harness/src/verify.ts +++ b/projects/research/pi-admission/pi-harness/src/verify.ts @@ -4,58 +4,19 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; -import { parseArgs } from "node:util"; -import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; import { AdmissionSession } from "./session.js"; -import { configureProxy } from "./network.js"; -import { loadSelectedModel } from "./model.js"; +import { loadSessionOptions } from "./config.js"; async function verify(): Promise { - configureProxy(); - const { values } = parseArgs({ options: { admission: { type: "string" } } }); - const apiKey = process.env.PI_MODEL_API_KEY; - const admissionKey = process.env.PI_ADMISSION_TOKEN; - delete process.env.PI_MODEL_API_KEY; - delete process.env.PI_ADMISSION_TOKEN; - assert.ok( - apiKey && admissionKey && values.admission, - "Missing example configuration", - ); - const model = await loadSelectedModel(); - const session = await AdmissionSession.create({ - cwd: "/sandbox/workspace", - sessionDir: "/sandbox/sessions", - agentDir: "/app/agent", - model, - apiKey, - admission: new Admission( - createHttpEvaluator(values.admission, admissionKey, randomUUID()), - ), - }); - - const raw = await fetch(`${model.baseUrl}/chat/completions`, { - method: "POST", - headers: { - authorization: `Bearer ${apiKey}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - model: model.id, - messages: [{ role: "user", content: "Harmless bypass check" }], - }), - signal: AbortSignal.timeout(30_000), - }); - assert.equal(raw.status, 403, "A request without a receipt must be blocked"); - console.log("PASS request without receipt is blocked"); + const { mode, session: options } = await loadSessionOptions(); + const session = await AdmissionSession.create(options); + const syntheticKey = `sk-${mode}_${randomUUID().replaceAll("-", "_")}`; - await assert.rejects( - session.prompt("123-45-6789"), - (error) => error instanceof AdmissionError && error.kind === "denied", + console.log(`Local admission: ${mode}`); + console.log(`Transcript: ${session.sessionFile}`); + await session.prompt( + `Acknowledge only; do not use tools. Synthetic key: ${syntheticKey}`, ); - assert.deepEqual(session.entries, []); - console.log("PASS denied input is absent from live history"); - - await session.prompt("Reply briefly to this harmless text: alice@example.com"); await session.prompt( "Use the write tool to create verify-tool.txt containing only: safe text", ); @@ -68,30 +29,31 @@ async function verify(): Promise { ), "The model must complete one successful write-tool call", ); + const saved = await readFile(session.sessionFile, "utf8"); for (const snapshot of [JSON.stringify(session.history), saved]) { - assert.ok(snapshot.includes("[EMAIL]")); - assert.ok( - !snapshot.includes("alice@example.com") && - !snapshot.includes("123-45-6789"), - ); + if (mode === "on") { + assert.ok(snapshot.includes("[REDACTED]")); + assert.ok(!snapshot.includes(syntheticKey)); + } else { + assert.ok(snapshot.includes(syntheticKey)); + } } - console.log("PASS redaction occurs before live and saved history"); - console.log("PASS a real tool result is admitted before continuation"); + console.log(`PASS ${mode} mode persisted the expected user-message value`); + console.log("PASS native write tool completed through the controlled loop"); session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); assert.ok(await session.compact(), "Compaction must use the admission boundary"); - assert.ok( - !(await readFile(session.sessionFile, "utf8")).includes("alice@example.com"), - ); - console.log("PASS compaction preserves admitted history"); - console.log(`Saved evidence: ${session.sessionFile}`); + if (mode === "on") + assert.ok(!(await readFile(session.sessionFile, "utf8")).includes(syntheticKey)); + console.log("PASS manual compaction completed through local admission"); } if (import.meta.main) { - verify().catch(() => { + verify().catch((error: unknown) => { + console.error(error); console.error( - "FAIL end-to-end verification. Check service availability, credentials, model compatibility, and the last PASS line.", + "FAIL live verification. Check the gateway, model credential, model compatibility, and the last PASS line.", ); process.exitCode = 1; }); diff --git a/projects/research/pi-admission/pi-harness/test/e2e.test.ts b/projects/research/pi-admission/pi-harness/test/e2e.test.ts index e68a3b32..b4af518f 100644 --- a/projects/research/pi-admission/pi-harness/test/e2e.test.ts +++ b/projects/research/pi-admission/pi-harness/test/e2e.test.ts @@ -6,20 +6,17 @@ import { mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; import { createAssistantMessageEventStream, type AssistantMessage, type Context, type Model, } from "@earendil-works/pi-ai"; -import type { StreamFn } from "@earendil-works/pi-agent-core"; -import { - Admission, - type AdmissionResponse, - type Evaluate, -} from "../src/admission.js"; -import { AdmissionSession } from "../src/session.js"; +import { Admission, AdmissionError, type AdmissionMode } from "../src/admission.js"; +import { AdmissionSession, createAdmissionRuntime } from "../src/session.js"; +const syntheticKey = "sk-LOCAL_TEST_ONLY_123456789"; const model: Model<"openai-completions"> = { id: "test", name: "Test", @@ -32,11 +29,6 @@ const model: Model<"openai-completions"> = { maxTokens: 4096, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }; -const allow: AdmissionResponse = { - decision: "allow", - replacement: null, - receipt: null, -}; function answer(text: string): AssistantMessage { return { @@ -62,7 +54,11 @@ function writeCall(): AssistantMessage { return { ...answer(""), content: [ - { type: "thinking", thinking: "Use the native write tool." }, + { + type: "thinking", + thinking: `Use the native write tool. ${syntheticKey}`, + thinkingSignature: "reasoning_content", + }, { type: "toolCall", id: "write-1", @@ -75,32 +71,75 @@ function writeCall(): AssistantMessage { }; } -async function fixture(evaluate: Evaluate) { +function multiBlockAnswer(): AssistantMessage { + return { + ...answer(""), + content: [ + { type: "text", text: "assistant output" }, + { type: "text", text: syntheticKey }, + ], + }; +} + +function bashCall(): AssistantMessage { + return { + ...answer(""), + content: [ + { + type: "toolCall", + id: "bash-1", + name: "bash", + // The unmodified call constructs the value only at execution time. Its + // result then exercises the tool-result admission boundary. + arguments: { + command: "printf 'sk-%s%s\\n' 'LOCAL_TEST_' 'ONLY_123456789'", + }, + }, + ], + stopReason: "toolUse", + }; +} + +function result(message: AssistantMessage) { + const stream = createAssistantMessageEventStream(); + stream.push({ + type: "done", + reason: message.stopReason === "toolUse" ? "toolUse" : "stop", + message, + }); + return stream; +} + +async function fixture(mode: AdmissionMode, stream?: StreamFn) { const cwd = await mkdtemp(join(tmpdir(), "pi-admission-e2e-")); const requests: Context[] = []; - const stream: StreamFn = (_model, context, options) => { - assert.equal(options?.headers?.["x-pi-admission-receipt"], "receipt"); + const defaultStream: StreamFn = (_model, context, options) => { assert.equal(options?.reasoning, "medium"); + assert.equal(options?.apiKey, "placeholder"); requests.push(structuredClone({ ...context, tools: undefined })); - const result = createAssistantMessageEventStream(); - const message = requests.length === 1 ? writeCall() : answer("Done"); - result.push({ - type: "done", - reason: message.stopReason === "toolUse" ? "toolUse" : "stop", - message, - }); - return result; + const message = + requests.length === 1 + ? writeCall() + : requests.length === 2 + ? bashCall() + : requests.length === 3 + ? multiBlockAnswer() + : answer(`compaction summary ${syntheticKey}`); + return result(message); }; - const session = await AdmissionSession.create({ + const runtime = await createAdmissionRuntime({ cwd, sessionDir: join(cwd, "sessions"), agentDir: join(cwd, "agent"), model, apiKey: "placeholder", - admission: new Admission(evaluate), - stream, + admission: new Admission(mode), + stream: stream ?? defaultStream, }); - return { cwd, session, requests }; + const session = runtime.session; + assert.ok(session instanceof AdmissionSession); + await session.bindExtensions({}); + return { cwd, session, requests, runtime }; } async function saved(session: AdmissionSession): Promise { @@ -112,99 +151,114 @@ async function saved(session: AdmissionSession): Promise { } } -test("allowed and redacted input reaches the provider and saved history with a receipt", async () => { - const kinds: string[] = []; - let releaseTool!: () => void; - let toolPending!: () => void; - const toolGate = new Promise((resolve) => (releaseTool = resolve)); - const pending = new Promise((resolve) => (toolPending = resolve)); - const { cwd, session, requests } = await fixture(async (kind, body) => { - kinds.push(kind); - if (kind === "provider_context") return { ...allow, receipt: "receipt" }; - if (kind === "tool_result") { - toolPending(); - await toolGate; +for (const mode of ["off", "on"] as const) { + test(`admission ${mode} controls every supported publication boundary`, async () => { + const { cwd, session, requests, runtime } = await fixture(mode); + for (const change of [ + () => runtime.newSession(), + () => runtime.switchSession("unchecked.jsonl"), + () => runtime.importFromJsonl("unchecked.jsonl"), + () => runtime.fork("unchecked-entry"), + ]) + assert.deepEqual(await change(), { cancelled: true }); + + assert.deepEqual(session.getActiveToolNames(), ["read", "bash", "edit", "write"]); + await session.prompt(`user input ${syntheticKey}`); + assert.equal(await readFile(join(cwd, "tool-output.txt"), "utf8"), "written by Pi"); + assert.equal(requests.length, 3); + const finalAssistant = session.history.at(-1); + assert.equal(finalAssistant?.role, "assistant"); + assert.equal(finalAssistant?.content.length, 2); + + const snapshots = [ + JSON.stringify(requests), + JSON.stringify(session.history), + await saved(session), + ]; + assert.ok(snapshots.every((snapshot) => snapshot.includes("opaque-replay-data"))); + assert.ok(snapshots.every((snapshot) => snapshot.includes("reasoning_content"))); + assert.ok(snapshots.every((snapshot) => snapshot.includes("Successfully wrote"))); + if (mode === "on") { + assert.ok(snapshots.every((snapshot) => snapshot.includes("[REDACTED]"))); + assert.ok(snapshots.every((snapshot) => !snapshot.includes(syntheticKey))); + } else { + assert.ok(snapshots.every((snapshot) => snapshot.includes(syntheticKey))); } - if (kind === "user_message" && body.text === "alice@example.com") { - return { - decision: "replace", - replacement: { ...body, text: "[EMAIL]" }, - receipt: null, - }; + + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + assert.ok(await session.compact()); + const compaction = session.entries.find((entry) => entry.type === "compaction"); + assert.ok(compaction && (!('details' in compaction) || compaction.details === undefined)); + const summary = JSON.stringify(compaction); + if (mode === "on") { + assert.ok(summary.includes("[REDACTED]")); + assert.ok(!summary.includes(syntheticKey)); + } else { + assert.ok(summary.includes(syntheticKey)); } - return allow; }); +} - assert.deepEqual(session.getActiveToolNames(), ["read", "bash", "edit", "write"]); - const firstTurn = session.prompt("plain text"); - await pending; - assert.equal(session.history.some((message) => message.role === "assistant"), false); - releaseTool(); - await firstTurn; - await session.prompt("alice@example.com"); - - assert.equal(requests.length, 3); - assert.ok(kinds.includes("tool_result")); - assert.equal( - await readFile(join(cwd, "tool-output.txt"), "utf8"), - "written by Pi", - ); - const snapshots = [ - JSON.stringify(requests), - JSON.stringify(session.history), - await saved(session), - ]; - assert.ok( - snapshots.every((snapshot) => snapshot.includes("Successfully wrote")), - ); - assert.ok(snapshots.every((snapshot) => snapshot.includes("plain text"))); - assert.ok(snapshots.every((snapshot) => snapshot.includes("opaque-replay-data"))); - assert.ok(snapshots.every((snapshot) => snapshot.includes("[EMAIL]"))); - assert.ok( - snapshots.every((snapshot) => !snapshot.includes("alice@example.com")), - ); +test("signed transformations are rejected before assistant publication", async () => { + const signed = answer(syntheticKey); + signed.content = [{ type: "text", text: syntheticKey, textSignature: "signed" }]; + const { session } = await fixture("on", () => result(signed)); - session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); - assert.ok(await session.compact()); - assert.ok(kinds.includes("compaction_summary")); - const compaction = session.entries.find((entry) => entry.type === "compaction"); - assert.ok( - compaction && (!("details" in compaction) || compaction.details === undefined), + await assert.rejects( + session.prompt("safe user text"), + (error) => error instanceof AdmissionError && error.kind === "invalid", ); -}); + assert.equal(session.history.some((message) => message.role === "assistant"), false); + assert.ok(!(await saved(session)).includes(syntheticKey)); -test("denied input never reaches the provider, live history, or saved history", async () => { - const forbidden = "123-45-6789"; - const { session, requests } = await fixture(async (kind) => - kind === "user_message" - ? { decision: "deny", replacement: null, receipt: null } - : allow, + const opaqueReasoning = answer(""); + opaqueReasoning.content = [ + { + type: "thinking", + thinking: syntheticKey, + thinkingSignature: "opaque-reasoning-signature", + }, + ]; + await assert.rejects( + new Admission("on").message(opaqueReasoning), + (error) => error instanceof AdmissionError && error.kind === "invalid", ); +}); - await assert.rejects(session.prompt(forbidden)); +test("tool calls that would require semantic rewriting are rejected", async () => { + const call = writeCall(); + const toolCall = call.content.find((block) => block.type === "toolCall"); + assert.ok(toolCall?.type === "toolCall"); + toolCall.arguments = { path: "unsafe.txt", content: syntheticKey }; + const { cwd, session } = await fixture("on", () => result(call)); - assert.deepEqual(requests, []); - assert.ok(!JSON.stringify(session.history).includes(forbidden)); - assert.ok(!JSON.stringify(session.entries).includes(forbidden)); - assert.ok(!(await saved(session)).includes(forbidden)); + await assert.rejects( + session.prompt("safe user text"), + (error) => error instanceof AdmissionError && error.kind === "invalid", + ); + await assert.rejects(readFile(join(cwd, "unsafe.txt")), { code: "ENOENT" }); + assert.equal(session.history.some((message) => message.role === "assistant"), false); +}); +test("cancellation does not publish an incomplete assistant response", async () => { let release!: () => void; - let admissionPending!: () => void; + let started!: () => void; const gate = new Promise((resolve) => (release = resolve)); - const pending = new Promise((resolve) => (admissionPending = resolve)); - const cancelled = await fixture(async (kind) => { - if (kind === "user_message") { - admissionPending(); - await gate; - } - return kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow; - }); - const turn = cancelled.session.prompt("cancel me"); + const pending = new Promise((resolve) => (started = resolve)); + const stream: StreamFn = () => { + const events = createAssistantMessageEventStream(); + started(); + void gate.then(() => events.push({ type: "done", reason: "stop", message: answer("late") })); + return events; + }; + const { session } = await fixture("on", stream); + const turn = session.prompt("checked user text"); await pending; - await assert.rejects(cancelled.session.prompt("busy"), /busy/); - const abort = cancelled.session.abort(); + await assert.rejects(session.prompt("busy"), /busy/); + const abort = session.abort(); release(); await abort; await assert.rejects(turn); - assert.equal(JSON.stringify(cancelled.session.history).includes("cancel me"), false); + assert.equal(session.history.some((message) => message.role === "assistant"), false); + assert.ok(!(await saved(session)).includes("late")); }); From bdfa5b6af61780cce8cae8ee1d67519cf1f48b9c Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 21 Sep 2026 14:41:08 +0000 Subject: [PATCH 11/14] refactor(pi-admission): remove external admission service --- projects/research/pi-admission/.env.example | 3 - .../research/pi-admission/bind-sandbox.py | 25 - projects/research/pi-admission/demo.sh | 93 +- .../pi-admission/middleware/.gitignore | 7 - .../.openshell-middleware-manifest.json | 13 - .../pi-admission/middleware/Cargo.lock | 2010 ----------------- .../pi-admission/middleware/Cargo.toml | 35 - .../pi-admission/middleware/README.md | 20 - .../research/pi-admission/middleware/build.rs | 16 - .../proto/supervisor_middleware.proto | 413 ---- .../pi-admission/middleware/src/admission.rs | 254 --- .../pi-admission/middleware/src/auth.rs | 116 - .../pi-admission/middleware/src/lib.rs | 210 -- .../pi-admission/middleware/src/main.rs | 53 - .../pi-admission/middleware/src/policy.rs | 361 --- .../pi-admission/middleware/src/receipt.rs | 302 --- projects/research/pi-admission/policy.yaml | 24 +- projects/research/pi-admission/prepare.py | 257 +-- projects/research/pi-admission/pyproject.toml | 2 - .../research/pi-admission/sandbox/Dockerfile | 2 - projects/research/pi-admission/uv.lock | 185 +- 21 files changed, 64 insertions(+), 4337 deletions(-) delete mode 100644 projects/research/pi-admission/bind-sandbox.py delete mode 100644 projects/research/pi-admission/middleware/.gitignore delete mode 100644 projects/research/pi-admission/middleware/.openshell-middleware-manifest.json delete mode 100644 projects/research/pi-admission/middleware/Cargo.lock delete mode 100644 projects/research/pi-admission/middleware/Cargo.toml delete mode 100644 projects/research/pi-admission/middleware/README.md delete mode 100644 projects/research/pi-admission/middleware/build.rs delete mode 100644 projects/research/pi-admission/middleware/proto/supervisor_middleware.proto delete mode 100644 projects/research/pi-admission/middleware/src/admission.rs delete mode 100644 projects/research/pi-admission/middleware/src/auth.rs delete mode 100644 projects/research/pi-admission/middleware/src/lib.rs delete mode 100644 projects/research/pi-admission/middleware/src/main.rs delete mode 100644 projects/research/pi-admission/middleware/src/policy.rs delete mode 100644 projects/research/pi-admission/middleware/src/receipt.rs diff --git a/projects/research/pi-admission/.env.example b/projects/research/pi-admission/.env.example index 6cace5b2..b65897ad 100644 --- a/projects/research/pi-admission/.env.example +++ b/projects/research/pi-admission/.env.example @@ -1,8 +1,5 @@ # Existing HTTPS/mTLS gateway registered in your OpenShell CLI (gateway list). OPENSHELL_GATEWAY=your-gateway -# Pi admission service hostname or IPv4 address reachable from gateway AND sandbox. -# Use reachable DNS or a LAN IPv4 address; Docker-only names may fail on the host. -PI_ADMISSION_HOST=your-service-host # Required only when models.json declares more than one model. # PI_MODEL=openrouter/z-ai/glm-5.3-flash # Key for the provider in models.json (OpenRouter in the supplied example). diff --git a/projects/research/pi-admission/bind-sandbox.py b/projects/research/pi-admission/bind-sandbox.py deleted file mode 100644 index 68a66c9e..00000000 --- a/projects/research/pi-admission/bind-sandbox.py +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Bind the service's demo credential to an operator-observed sandbox ID.""" - -import argparse -import json -import sys -from pathlib import Path - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--state", type=Path, required=True) - args = parser.parse_args() - sandbox = json.load(sys.stdin) - identifier = sandbox["id"] - if not isinstance(identifier, str) or not identifier: - raise ValueError("OpenShell did not return a sandbox ID") - (args.state / "sandbox-id").write_text(identifier + "\n") - print("Admission identity bound. Run ./demo.sh launch or ./demo.sh verify.") - - -if __name__ == "__main__": - main() diff --git a/projects/research/pi-admission/demo.sh b/projects/research/pi-admission/demo.sh index 4c21bf33..85ece8ff 100755 --- a/projects/research/pi-admission/demo.sh +++ b/projects/research/pi-admission/demo.sh @@ -10,13 +10,13 @@ state=$example/.workspaces print_only=false if [[ ${1:-} == --print ]]; then print_only=true; shift; fi action=${1:-help} +shift || true # .env is trusted operator input. Print mode never executes it. if ! $print_only && [[ -f $example/.env ]]; then set -a source "$example/.env" set +a fi -service_host=${PI_ADMISSION_HOST:-YOUR_SERVICE_HOST} gateway=${OPENSHELL_GATEWAY:-YOUR_GATEWAY} openshell=(openshell --gateway "$gateway") run() { @@ -30,7 +30,6 @@ delete_if_present() { printf '%s\n' "$output" else status=$? - # Older OpenShell releases return gRPC NotFound for an absent resource. if [[ $output == *"code: 'Some requested entity was not found'"* && $output == *"message: \"$resource not found\""* ]]; then printf '%s already absent; continuing cleanup.\n' "$resource" @@ -40,84 +39,60 @@ delete_if_present() { fi fi } +admission_mode() { + if [[ ${1:-} != --admission || (${2:-} != off && ${2:-} != on) || $# != 2 ]]; then + echo "Usage: ./demo.sh $action --admission off|on" >&2 + exit 2 + fi + printf '%s' "$2" +} cd "$example" case "$action" in prepare) - if ! $print_only; then - : "${PI_ADMISSION_HOST:?Set the service hostname or IPv4 address in .env}" - : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" - if [[ ! -f $example/models.json ]]; then - echo 'Create models.json from models.json.example and configure your model first.' >&2 - exit 1 - fi + if (( $# )); then echo "prepare takes no arguments" >&2; exit 2; fi + if ! $print_only && [[ ! -f $example/models.json ]]; then + echo 'Create models.json from models.json.example and configure your model first.' >&2 + exit 1 fi run uv sync --frozen - if $print_only; then - printf '%q ' "${openshell[@]}" gateway list --output json - printf '| ' - run uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" --model "${PI_MODEL:-}" - else - "${openshell[@]}" gateway list --output json | uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" --model "${PI_MODEL:-}" - fi + run uv run --frozen python "$example/prepare.py" --state "$state" --model "${PI_MODEL:-}" run docker build --tag pi-admission:local "$state/image" ;; - serve) - run cargo run --locked --manifest-path "$example/middleware/Cargo.toml" -- --config "$state/admission.json" - ;; - registration) - run cat "$state/middleware.toml" - ;; setup) + if (( $# )); then echo "setup takes no arguments" >&2; exit 2; fi if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" : "${PI_MODEL_API_KEY:?Set PI_MODEL_API_KEY in the example .env}" export PI_MODEL_API_KEY - PI_ADMISSION_TOKEN=$(uv run --frozen python -c 'import json,sys; print(json.load(open(sys.argv[1]))["bearer_token"])' "$state/admission.json") - export PI_ADMISSION_TOKEN fi run "${openshell[@]}" gateway info - for provider in model admission; do - run "${openshell[@]}" provider profile import --file "$state/$provider-provider.yaml" - variable=PI_MODEL_API_KEY - [[ $provider != admission ]] || variable=PI_ADMISSION_TOKEN - run "${openshell[@]}" provider create --name "pi-admission-$provider" --type "pi-admission-$provider" --credential "$variable" - done - run "${openshell[@]}" sandbox create --name pi-admission --from pi-admission:local --policy "$state/policy.yaml" --provider pi-admission-model --provider pi-admission-admission --detach -- /bin/sleep infinity - if $print_only; then - printf '%q ' "${openshell[@]}" sandbox get pi-admission --output json - printf '| uv run --frozen python %q --state %q\n' "$example/bind-sandbox.py" "$state" - else - "${openshell[@]}" sandbox get pi-admission --output json | uv run --frozen python "$example/bind-sandbox.py" --state "$state" - fi - ;; - launch) - if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${PI_ADMISSION_HOST:?Set the service host in .env}"; fi - run "${openshell[@]}" sandbox exec --tty --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/cli.js --admission "https://$service_host:5443/v1/admission" + run "${openshell[@]}" provider profile import --file "$state/model-provider.yaml" + run "${openshell[@]}" provider create --name pi-admission-model --type pi-admission-model --credential PI_MODEL_API_KEY + run "${openshell[@]}" sandbox create --name pi-admission --from pi-admission:local --policy "$state/policy.yaml" --provider pi-admission-model --detach -- /bin/sleep infinity ;; - verify) - if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${PI_ADMISSION_HOST:?Set the service host in .env}"; fi - run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/verify.js --admission "https://$service_host:5443/v1/admission" + launch|verify) + mode=$(admission_mode "$@") + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi + tty=--tty + entry=cli + if [[ $action == verify ]]; then tty=--no-tty; entry=verify; fi + run "${openshell[@]}" sandbox exec "$tty" --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA "/app/dist/src/$entry.js" --admission "$mode" ;; cleanup) + if (( $# )); then echo "cleanup takes no arguments" >&2; exit 2; fi if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi delete_if_present sandbox "${openshell[@]}" sandbox delete pi-admission - for provider in model admission; do - delete_if_present provider "${openshell[@]}" provider delete "pi-admission-$provider" - delete_if_present 'provider profile' "${openshell[@]}" provider profile delete "pi-admission-$provider" - done - run uv run --frozen python -c 'import pathlib,sys; pathlib.Path(sys.argv[1]).unlink(missing_ok=True)' "$state/sandbox-id" - printf 'Sandbox and its sessions removed. Stop serve with Ctrl-C.\n' - printf 'Host configuration remains in %s; the local Docker image is retained.\n' "$state" + delete_if_present provider "${openshell[@]}" provider delete pi-admission-model + delete_if_present 'provider profile' "${openshell[@]}" provider profile delete pi-admission-model + printf 'Sandbox and its sessions removed. The local Docker image is retained.\n' ;; help) - printf 'Usage: ./demo.sh [--print] ACTION\n\n' - printf ' prepare Generate service TLS/config; build the Pi image\n' - printf ' serve Run Pi admission service (keep this terminal open)\n' - printf ' registration Show the gateway middleware TOML entry\n' - printf ' setup Create providers and sandbox; bind admission identity\n' - printf ' launch Start a new interactive Pi-powered session\n' - printf ' verify Run real deny/redact/history/compaction and bypass checks\n' - printf ' cleanup Delete sandbox, providers, and sessions\n' + printf 'Usage: ./demo.sh [--print] ACTION [OPTIONS]\n\n' + printf ' prepare Generate policy/model config and build the Pi image\n' + printf ' setup Create the model provider and shared sandbox\n' + printf ' launch --admission off|on Start a fresh interactive session\n' + printf ' verify --admission off|on Run the paid live check in one mode\n' + printf ' cleanup Delete the sandbox, provider, and sessions\n' printf '\n--print shows commands without executing .env, requiring secrets, or changing state.\n' ;; *) echo "Unknown action. Run ./demo.sh help." >&2; exit 2 ;; diff --git a/projects/research/pi-admission/middleware/.gitignore b/projects/research/pi-admission/middleware/.gitignore deleted file mode 100644 index 6b269eab..00000000 --- a/projects/research/pi-admission/middleware/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -.DS_Store -.env -.env.* -!.env.example -*.key -*.pem -target/ diff --git a/projects/research/pi-admission/middleware/.openshell-middleware-manifest.json b/projects/research/pi-admission/middleware/.openshell-middleware-manifest.json deleted file mode 100644 index 676560af..00000000 --- a/projects/research/pi-admission/middleware/.openshell-middleware-manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "openshell_version": "v0.0.116", - "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.116/proto/supervisor_middleware.proto", - "proto_sha256": "d96a963321c74c261a912dcd0b8cda690741b32b8c3d90ff3ef38dafe6681bad", - "languages": [ - "rust" - ], - "python_package": null, - "generator": { - "name": "openshell-middleware-manager", - "version": "0.0.2.dev72+4d6909f" - } -} diff --git a/projects/research/pi-admission/middleware/Cargo.lock b/projects/research/pi-admission/middleware/Cargo.lock deleted file mode 100644 index 6ca1728e..00000000 --- a/projects/research/pi-admission/middleware/Cargo.lock +++ /dev/null @@ -1,2010 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "autotools" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" -dependencies = [ - "cc", -] - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-server" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" -dependencies = [ - "arc-swap", - "bytes", - "either", - "fs-err", - "http", - "http-body", - "hyper", - "hyper-util", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs-err" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" -dependencies = [ - "autocfg", - "tokio", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core", - "subtle", -] - -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "base64", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac", - "js-sys", - "p256", - "p384", - "pem", - "rand", - "rsa", - "serde", - "serde_json", - "sha2", - "signature", - "simple_asn1", - "zeroize", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - -[[package]] -name = "pi-admission" -version = "0.1.0" -dependencies = [ - "axum", - "axum-server", - "base64", - "bytes", - "ed25519-dalek", - "futures-core", - "jsonwebtoken", - "prost", - "prost-types", - "protobuf-src", - "rand", - "regex", - "serde", - "serde_json", - "sha2", - "subtle", - "tokio", - "tokio-stream", - "tonic", - "tonic-prost", - "tonic-prost-build", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" -dependencies = [ - "heck", - "itertools", - "log", - "multimap", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "pulldown-cmark", - "pulldown-cmark-to-cmark", - "regex", - "syn 2.0.119", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "prost-types" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" -dependencies = [ - "prost", -] - -[[package]] -name = "protobuf-src" -version = "1.1.0+21.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ac8852baeb3cc6fb83b93646fb93c0ffe5d14bf138c945ceb4b9948ee0e3c1" -dependencies = [ - "autotools", -] - -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags", - "memchr", - "unicase", -] - -[[package]] -name = "pulldown-cmark-to-cmark" -version = "22.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" -dependencies = [ - "pulldown-cmark", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core", -] - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-build" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" -dependencies = [ - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tonic-prost" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - -[[package]] -name = "tonic-prost-build" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "prost-types", - "quote", - "syn 2.0.119", - "tempfile", - "tonic-build", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "indexmap", - "pin-project-lite", - "slab", - "sync_wrapper", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.5", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/projects/research/pi-admission/middleware/Cargo.toml b/projects/research/pi-admission/middleware/Cargo.toml deleted file mode 100644 index e1c9472e..00000000 --- a/projects/research/pi-admission/middleware/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -[package] -name = "pi-admission" -version = "0.1.0" -edition = "2024" -rust-version = "1.90" -publish = false -license = "Apache-2.0" - -[lib] -name = "pi_admission" - -[dependencies] -axum = "0.8" -axum-server = { version = "0.8", features = ["tls-rustls-no-provider"] } -base64 = "0.22" -bytes = "1" -ed25519-dalek = { version = "2", features = ["pem", "pkcs8", "rand_core"] } -futures-core = "0.3" -jsonwebtoken = { version = "10", features = ["rust_crypto"] } -prost = "0.14" -prost-types = "0.14" -rand = "0.8" -regex = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -subtle = "2" -tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal"] } -tokio-stream = { version = "0.1", features = ["net"] } -tonic = { version = "0.14", features = ["tls-ring"] } -tonic-prost = "0.14" - -[build-dependencies] -protobuf-src = "1.1.0" -tonic-prost-build = "0.14" diff --git a/projects/research/pi-admission/middleware/README.md b/projects/research/pi-admission/middleware/README.md deleted file mode 100644 index a1d3d130..00000000 --- a/projects/research/pi-admission/middleware/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Pi admission middleware - -This OMM-managed Rust service exposes authenticated admission HTTPS on port -5443 and the OpenShell `v0.0.116` pre-credentials middleware contract over TLS -on port 50051. Run it from the parent example with `./demo.sh serve`. - -OMM owns `.openshell-middleware-manifest.json`, -`proto/supervisor_middleware.proto`, and `Cargo.lock`. Refresh those together: - -```sh -omm update --openshell-version v0.0.116 -``` - -Validate handwritten code with: - -```sh -cargo fmt --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --locked -``` diff --git a/projects/research/pi-admission/middleware/build.rs b/projects/research/pi-admission/middleware/build.rs deleted file mode 100644 index ff030cc9..00000000 --- a/projects/research/pi-admission/middleware/build.rs +++ /dev/null @@ -1,16 +0,0 @@ -use std::error::Error; - -fn main() -> Result<(), Box> { - // Bundle protoc so contributors do not need a separate installation. - unsafe { - std::env::set_var("PROTOC", protobuf_src::protoc()); - } - - println!("cargo:rerun-if-changed=proto/supervisor_middleware.proto"); - tonic_prost_build::configure() - .build_client(true) - .build_server(true) - .compile_protos(&["proto/supervisor_middleware.proto"], &["proto"])?; - - Ok(()) -} diff --git a/projects/research/pi-admission/middleware/proto/supervisor_middleware.proto b/projects/research/pi-admission/middleware/proto/supervisor_middleware.proto deleted file mode 100644 index 27fd804b..00000000 --- a/projects/research/pi-admission/middleware/proto/supervisor_middleware.proto +++ /dev/null @@ -1,413 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -syntax = "proto3"; - -package openshell.middleware.v1; - -import "google/protobuf/empty.proto"; -import "google/protobuf/struct.proto"; - -// SupervisorMiddleware lets an operator-run service inspect and transform -// sandbox HTTP requests and client WebSocket text messages before OpenShell -// injects credentials. -service SupervisorMiddleware { - // Describe returns the service manifest and declared bindings. - rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); - - // ValidateConfig checks service-specific configuration for one binding. - rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); - - // EvaluateHttpRequest returns an allow, deny, or mutation decision for one - // buffered HTTP request. - rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); - - // 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 -// exposes. The service is the operator-run gRPC server implementing -// SupervisorMiddleware. -message MiddlewareManifest { - // Human-readable middleware service name used only for diagnostics. This is - // not required to match an operator-owned registration name. - string name = 1; - // Release version of the middleware service implementation, used for - // diagnostics. - 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. - SupervisorMiddlewareOperation operation = 1; - // 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 logical payload or replacement this binding can process. For - // HTTP_REQUEST 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; -} - -// ValidateConfigRequest contains one policy configuration to validate. -message ValidateConfigRequest { - // Service-specific policy configuration. - google.protobuf.Struct config = 1; - // Built-in middleware name or operator-owned registration name. - string middleware_name = 2; -} - -// ValidateConfigResponse reports whether a policy configuration is accepted. -message ValidateConfigResponse { - // True when the service accepts the configuration. - bool valid = 1; - // Human-readable validation failure reason. Empty when valid is true. - string reason = 2; -} - -// HttpRequestEvaluation contains one buffered HTTP request to evaluate. -message HttpRequestEvaluation { - // Evaluation phase selected for this request. - SupervisorMiddlewarePhase phase = 1; - // Sandbox and request identity available to the supervisor. - // The encoded context is limited to 4 KiB. - RequestContext context = 2; - // Validated service-specific policy configuration. - // The encoded configuration is limited to 64 KiB. - google.protobuf.Struct config = 3; - // Destination and HTTP request target. - // The encoded target is limited to 32 KiB. - HttpRequestTarget target = 4; - // HTTP request headers before OpenShell injects credentials, in wire - // order. Repeated header names are preserved as separate entries. Protected - // credential, routing, framing, and hop-by-hop headers are omitted. - // At most 128 lines and 64 KiB of encoded headers are included. - repeated HttpHeader headers = 5; - // Buffered request body, limited to 4 MiB. Empty for a bodyless request. - bytes body = 6; - // Built-in middleware name or operator-owned registration name. - string middleware_name = 7; -} - -// HttpHeader is one request header line. -message HttpHeader { - // Lowercased header name. - string name = 1; - // Header value with surrounding whitespace trimmed. - string value = 2; -} - -// Supervisor operation selected for middleware evaluation. -enum SupervisorMiddlewareOperation { - SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; - SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; - SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; -} - -// 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; -} - -// 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. -message RequestContext { - // Request id used to correlate middleware and supervisor logs. - string request_id = 1; - // Sandbox id that originated the request. - 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", "https", "ws", or "wss". - string scheme = 1; - // Destination hostname selected by network policy. - string host = 2; - // Destination TCP port. - uint32 port = 3; - // HTTP request method. - string method = 4; - // Request path without the query string. - string path = 5; - // Raw request query string without the leading question mark. - string query = 6; -} - -// Process identifies a workload process and its executable ancestry. -message Process { - // Executable path for the originating process. - string binary = 1; - // Process id within the sandbox. - uint32 pid = 2; - // Executable paths for ancestor processes, nearest parent first. - repeated string ancestors = 3; -} - -// 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 current request or message and apply any returned - // mutations. - DECISION_ALLOW = 1; - // Reject the current request or message. The operation-specific result - // defines the enclosing protocol behavior. - DECISION_DENY = 2; -} - -// Finding is an audit-safe observation produced during evaluation. -message Finding { - // Stable, service-defined finding type. - string type = 1; - // Human-readable finding label that does not contain request content. - string label = 2; - // Number of matching observations represented by this finding. - uint32 count = 3; - // Service-defined confidence level. - string confidence = 4; - // Service-defined severity level. - string severity = 5; -} - -// ExistingHeaderAction controls how a header write behaves when the -// case-insensitive header name is already present. Every action writes the -// value when the header is absent. -enum ExistingHeaderAction { - EXISTING_HEADER_ACTION_UNSPECIFIED = 0; - // Add another field value without changing existing values. - EXISTING_HEADER_ACTION_APPEND = 1; - // Remove every existing value, then add the new value. - EXISTING_HEADER_ACTION_OVERWRITE = 2; - // Leave the existing values unchanged. - EXISTING_HEADER_ACTION_SKIP = 3; -} - -// WriteHeader proposes one header value and defines collision behavior. -message WriteHeader { - string name = 1; - string value = 2; - ExistingHeaderAction on_existing = 3; -} - -// RemoveHeader removes every value for a case-insensitive header name. -message RemoveHeader { - string name = 1; -} - -// HeaderMutation is one ordered request-header operation. -message HeaderMutation { - oneof operation { - WriteHeader write = 1; - RemoveHeader remove = 2; - } -} - -// HttpRequestResult contains the decision and optional request mutations. -message HttpRequestResult { - // Allow or deny decision for this request. - Decision decision = 1; - // Free-form service diagnostic. OpenShell does not relay this text into - // denied responses or security logs. Limited to 4 KiB before discarding. - string reason = 2; - // Replacement request body when has_body is true. Limited to 4 MiB. - bytes body = 3; - // True when body should replace the request body, including with an empty body. - bool has_body = 4; - // Ordered request-header mutations applied before the next middleware and - // before forwarding. Header writes are restricted to the - // "x-openshell-middleware-" namespace. Removes may target other visible - // request headers, but credential, routing, framing, and hop-by-hop headers - // are always protected. A violating result is a middleware failure handled - // according to the policy failure mode. At most 64 operations, 32 KiB of - // validated name/value data, and 64 KiB encoded are accepted. - repeated HeaderMutation header_mutations = 5; - // Audit-safe findings produced during evaluation. For operator-run services, - // OpenShell logs platform-owned fields derived from the operator-owned - // registration name rather than service-provided type, label, confidence, - // or metadata text. - // At most 32 findings of at most 4 KiB encoded each are accepted per stage. - // A policy selects at most 10 stages, so one chain retains at most 320. - repeated Finding findings = 6; - // Non-secret service-defined metadata included in diagnostics. At most 64 - // entries and 32 KiB of combined key/value data are accepted. - map metadata = 7; - // Optional stable machine-readable code for a deny decision. Codes must - // start with a lowercase ASCII letter and contain only lowercase ASCII - // letters, digits, and underscores, with a maximum length of 64 bytes. - // OpenShell may return this code to the requester, unlike free-form reason. - string reason_code = 8; -} diff --git a/projects/research/pi-admission/middleware/src/admission.rs b/projects/research/pi-admission/middleware/src/admission.rs deleted file mode 100644 index 0cd14a17..00000000 --- a/projects/research/pi-admission/middleware/src/admission.rs +++ /dev/null @@ -1,254 +0,0 @@ -use std::{fs, path::PathBuf, sync::Arc}; - -use axum::{ - Json, Router, - body::Bytes, - extract::{DefaultBodyLimit, State}, - http::{HeaderMap, StatusCode, header}, - response::{IntoResponse, Response}, - routing::post, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use subtle::ConstantTimeEq; - -use crate::{ - MAX_BODY_BYTES, - auth::GatewayAuthentication, - policy::{CandidateDecision, POLICY_ID, Projection, ProviderTarget, evaluate_candidate}, - receipt::{ReceiptAuthority, ReceiptContext}, -}; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct AdmissionConfig { - pub listen: String, - pub tls_certificate: PathBuf, - pub tls_private_key: PathBuf, - pub gateway_public_key: PathBuf, - pub gateway_issuer: String, - pub gateway_audience: String, - pub middleware_name: String, - pub bearer_token: String, - pub sandbox_id_file: PathBuf, - pub provider_target: ProviderTarget, -} - -impl AdmissionConfig { - pub(crate) fn authentication( - &self, - ) -> Result> { - GatewayAuthentication::from_pem( - &self.gateway_public_key, - &self.gateway_issuer, - &self.gateway_audience, - ) - } -} - -#[derive(Clone)] -struct AppState { - config: Arc, - receipts: Arc, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AdmissionCall { - kind: String, - session_id: String, - submission_id: String, - body: Value, -} - -#[derive(Serialize)] -struct AdmissionResponse { - decision: &'static str, - reason_code: Option<&'static str>, - replacement: Option, - receipt: Option, - policy_identity: &'static str, -} - -pub fn admission_router(config: Arc, receipts: Arc) -> Router { - Router::new() - .route("/v1/admission", post(admit)) - .layer(DefaultBodyLimit::max(MAX_BODY_BYTES as usize)) - .with_state(AppState { config, receipts }) -} - -async fn admit(State(state): State, headers: HeaderMap, body: Bytes) -> Response { - if !authorized(&headers, &state.config.bearer_token) { - return (StatusCode::UNAUTHORIZED, "admission authentication failed").into_response(); - } - let call: AdmissionCall = match serde_json::from_slice(&body) { - Ok(call) => call, - Err(_) => return (StatusCode::BAD_REQUEST, "invalid admission request").into_response(), - }; - if !bounded_identifier(&call.session_id) || !bounded_identifier(&call.submission_id) { - return (StatusCode::BAD_REQUEST, "invalid admission request").into_response(); - } - let sandbox_id = match fs::read_to_string(&state.config.sandbox_id_file) { - Ok(value) if bounded_identifier(value.trim()) => value.trim().to_owned(), - _ => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - "admission is not provisioned", - ) - .into_response(); - } - }; - let response = match evaluate_candidate(&call.kind, call.body.clone()) { - CandidateDecision::Deny(code) => AdmissionResponse { - decision: "deny", - reason_code: Some(code), - replacement: None, - receipt: None, - policy_identity: POLICY_ID, - }, - CandidateDecision::Replace(replacement) => AdmissionResponse { - decision: "replace", - reason_code: None, - replacement: Some(replacement), - receipt: None, - policy_identity: POLICY_ID, - }, - CandidateDecision::Allow => { - let receipt = if call.kind == "provider_context" { - let projection: Projection = match serde_json::from_value( - call.body.get("entries").cloned().unwrap_or(Value::Null), - ) { - Ok(projection) => projection, - Err(_) => { - return (StatusCode::BAD_REQUEST, "invalid admission request") - .into_response(); - } - }; - let context = ReceiptContext { - middleware_name: &state.config.middleware_name, - sandbox_id: &sandbox_id, - target: &state.config.provider_target, - }; - match state.receipts.issue_header( - &projection, - context, - &call.session_id, - &call.submission_id, - ) { - Ok(receipt) => Some(receipt), - Err(_) => { - return (StatusCode::SERVICE_UNAVAILABLE, "admission is unavailable") - .into_response(); - } - } - } else { - None - }; - AdmissionResponse { - decision: "allow", - reason_code: None, - replacement: None, - receipt, - policy_identity: POLICY_ID, - } - } - }; - Json(response).into_response() -} - -fn authorized(headers: &HeaderMap, token: &str) -> bool { - let values: Vec<_> = headers.get_all(header::AUTHORIZATION).iter().collect(); - if values.len() != 1 { - return false; - } - let expected = format!("Bearer {token}"); - values[0].as_bytes().ct_eq(expected.as_bytes()).into() -} - -fn bounded_identifier(value: &str) -> bool { - !value.is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control) -} - -#[cfg(test)] -mod tests { - use super::*; - use ed25519_dalek::SigningKey; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::{TcpListener, TcpStream}, - }; - - #[tokio::test] - async fn real_http_transport_allows_replaces_denies_and_authenticates() { - let sandbox_id_file = - std::env::temp_dir().join(format!("pi-admission-sandbox-{}", std::process::id())); - fs::write(&sandbox_id_file, "sandbox-1\n").unwrap(); - let config = Arc::new(AdmissionConfig { - listen: "127.0.0.1:0".to_owned(), - tls_certificate: PathBuf::new(), - tls_private_key: PathBuf::new(), - gateway_public_key: PathBuf::new(), - gateway_issuer: "issuer".to_owned(), - gateway_audience: "audience".to_owned(), - middleware_name: "pi-admission".to_owned(), - bearer_token: "secret".to_owned(), - sandbox_id_file: sandbox_id_file.clone(), - provider_target: ProviderTarget { - scheme: "https".to_owned(), - host: "api.example.test".to_owned(), - port: 443, - method: "POST".to_owned(), - path: "/v1/chat/completions".to_owned(), - query: String::new(), - }, - }); - let receipts = Arc::new(ReceiptAuthority::new(SigningKey::from_bytes(&[3; 32]))); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - axum::serve(listener, admission_router(config, receipts)) - .await - .unwrap(); - }); - - let call = |text: &'static str, token: &'static str| async move { - let body = serde_json::json!({ - "kind": "user_message", - "session_id": "session", - "submission_id": "submission", - "body": { - "schema_version": "openshell.pi-message.v1", - "origin": "user", - "text": text - } - }) - .to_string(); - let request = format!( - "POST /v1/admission HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - let mut stream = TcpStream::connect(address).await.unwrap(); - stream.write_all(request.as_bytes()).await.unwrap(); - let mut response = Vec::new(); - stream.read_to_end(&mut response).await.unwrap(); - String::from_utf8(response).unwrap() - }; - - assert!( - call("plain text", "wrong") - .await - .starts_with("HTTP/1.1 401") - ); - let allowed = call("plain text", "secret").await; - assert!(allowed.starts_with("HTTP/1.1 200")); - assert!(allowed.contains(r#""decision":"allow""#)); - let replaced = call("alice@example.com", "secret").await; - assert!(replaced.contains(r#""decision":"replace""#)); - assert!(replaced.contains("[EMAIL]")); - let denied = call("123-45-6789", "secret").await; - assert!(denied.contains(r#""decision":"deny""#)); - - server.abort(); - fs::remove_file(sandbox_id_file).unwrap(); - } -} diff --git a/projects/research/pi-admission/middleware/src/auth.rs b/projects/research/pi-admission/middleware/src/auth.rs deleted file mode 100644 index eebfc709..00000000 --- a/projects/research/pi-admission/middleware/src/auth.rs +++ /dev/null @@ -1,116 +0,0 @@ -use std::{error::Error, fs, path::Path}; - -use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; -use serde::Deserialize; -use tonic::{Status, metadata::MetadataMap}; - -#[derive(Clone)] -pub(crate) struct GatewayAuthentication { - key: DecodingKey, - validation: Validation, -} - -#[derive(Deserialize)] -struct Claims { - caller_kind: String, - sandbox_id: Option, -} - -impl GatewayAuthentication { - pub(crate) fn from_pem( - path: &Path, - issuer: &str, - audience: &str, - ) -> Result> { - let key = DecodingKey::from_ed_pem(&fs::read(path)?)?; - let mut validation = Validation::new(Algorithm::EdDSA); - validation.set_issuer(&[issuer]); - validation.set_audience(&[audience]); - validation.set_required_spec_claims(&["iss", "aud", "exp", "iat"]); - Ok(Self { key, validation }) - } - - pub(crate) fn verify( - &self, - metadata: &MetadataMap, - expected_kinds: &[&str], - sandbox_id: Option<&str>, - ) -> Result<(), Status> { - let values: Vec<_> = metadata.get_all("authorization").iter().collect(); - if values.len() != 1 { - return Err(Status::unauthenticated("authentication required")); - } - let value = values[0] - .to_str() - .map_err(|_| Status::unauthenticated("authentication required"))?; - let token = value - .strip_prefix("Bearer ") - .filter(|token| !token.is_empty()) - .ok_or_else(|| Status::unauthenticated("authentication required"))?; - let header = - decode_header(token).map_err(|_| Status::unauthenticated("authentication failed"))?; - if header.typ.as_deref() != Some("openshell-ext+jwt") { - return Err(Status::unauthenticated("incorrect token type")); - } - let claims = decode::(token, &self.key, &self.validation) - .map_err(|_| Status::unauthenticated("authentication failed"))? - .claims; - if !expected_kinds.contains(&claims.caller_kind.as_str()) - || sandbox_id.is_some_and(|expected| claims.sandbox_id.as_deref() != Some(expected)) - { - return Err(Status::permission_denied("caller context mismatch")); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ed25519_dalek::{SigningKey, pkcs8::EncodePrivateKey}; - use jsonwebtoken::{EncodingKey, Header, encode}; - use rand::rngs::OsRng; - use serde_json::json; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn gateway_token_authenticates_and_binds_caller() { - let key = SigningKey::generate(&mut OsRng); - let mut validation = Validation::new(Algorithm::EdDSA); - validation.set_issuer(&["gateway"]); - validation.set_audience(&["pi-admission"]); - let auth = GatewayAuthentication { - key: DecodingKey::from_ed_der(key.verifying_key().as_bytes()), - validation, - }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - let claims = json!({"iss": "gateway", "aud": "pi-admission", "iat": now, - "exp": now + 60, "caller_kind": "supervisor", "sandbox_id": "sandbox"}); - let mut header = Header::new(Algorithm::EdDSA); - header.typ = Some("openshell-ext+jwt".into()); - let token = encode( - &header, - &claims, - &EncodingKey::from_ed_der(key.to_pkcs8_der().unwrap().as_bytes()), - ) - .unwrap(); - let mut metadata = MetadataMap::new(); - metadata.insert("authorization", format!("Bearer {token}").parse().unwrap()); - assert!( - auth.verify(&metadata, &["supervisor"], Some("sandbox")) - .is_ok() - ); - assert!( - auth.verify(&metadata, &["supervisor"], Some("other")) - .is_err() - ); - assert!(auth.verify(&metadata, &["gateway"], None).is_err()); - assert!( - auth.verify(&metadata, &["gateway", "supervisor"], None) - .is_ok() - ); - } -} diff --git a/projects/research/pi-admission/middleware/src/lib.rs b/projects/research/pi-admission/middleware/src/lib.rs deleted file mode 100644 index 49a00673..00000000 --- a/projects/research/pi-admission/middleware/src/lib.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Standalone admission and egress enforcement for the Pi example. - -mod admission; -mod auth; -mod policy; -mod receipt; - -use std::{pin::Pin, sync::Arc}; - -use futures_core::Stream; -use tonic::{Request, Response, Status}; - -use auth::GatewayAuthentication; -use policy::{ProviderTarget, inspect_provider_request}; -use receipt::ReceiptContext; - -pub use admission::{AdmissionConfig, admission_router}; -pub use receipt::ReceiptAuthority; - -#[allow(clippy::large_enum_variant)] -pub mod pb { - tonic::include_proto!("openshell.middleware.v1"); -} - -use pb::supervisor_middleware_server::{SupervisorMiddleware, SupervisorMiddlewareServer}; - -pub const SERVICE_NAME: &str = "pi-admission"; -pub const SERVICE_VERSION: &str = "0.1.0"; -pub const MAX_BODY_BYTES: u64 = 4 * 1024 * 1024; -pub const MAX_MESSAGE_BYTES: usize = MAX_BODY_BYTES as usize + 1024 * 1024; -pub const RECEIPT_HEADER: &str = "x-pi-admission-receipt"; - -#[derive(Clone)] -pub struct Middleware { - authentication: Arc, - receipts: Arc, - config: Arc, -} - -impl Middleware { - pub fn from_config( - config: Arc, - receipts: Arc, - ) -> Result> { - let authentication = Arc::new(config.authentication()?); - Ok(Self::new(authentication, receipts, config)) - } - - fn new( - authentication: Arc, - receipts: Arc, - config: Arc, - ) -> Self { - Self { - authentication, - receipts, - config, - } - } - - pub fn service(self) -> SupervisorMiddlewareServer { - SupervisorMiddlewareServer::new(self) - .max_decoding_message_size(MAX_MESSAGE_BYTES) - .max_encoding_message_size(MAX_MESSAGE_BYTES) - } - - fn manifest(&self) -> pb::MiddlewareManifest { - pb::MiddlewareManifest { - name: SERVICE_NAME.to_owned(), - service_version: SERVICE_VERSION.to_owned(), - bindings: vec![pb::MiddlewareBinding { - operation: pb::SupervisorMiddlewareOperation::HttpRequest as i32, - phase: pb::SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: MAX_BODY_BYTES, - timeout: String::new(), - }], - expected_audience: self.config.gateway_audience.clone(), - } - } - - fn deny(code: &str) -> pb::HttpRequestResult { - pb::HttpRequestResult { - decision: pb::Decision::Deny as i32, - reason: "Pi admission denied the request".to_owned(), - reason_code: code.to_owned(), - ..Default::default() - } - } -} - -#[tonic::async_trait] -impl SupervisorMiddleware for Middleware { - type EvaluateWebSocketSessionStream = Pin< - Box> + Send + 'static>, - >; - - async fn describe( - &self, - request: Request<()>, - ) -> Result, Status> { - // Both gateway registration and sandbox startup discover the manifest. - self.authentication - .verify(request.metadata(), &["gateway", "supervisor"], None)?; - Ok(Response::new(self.manifest())) - } - - async fn validate_config( - &self, - request: Request, - ) -> Result, Status> { - self.authentication - .verify(request.metadata(), &["gateway"], None)?; - let body = request.into_inner(); - let valid = body.middleware_name == self.config.middleware_name - && body - .config - .as_ref() - .is_none_or(|config| config.fields.is_empty()); - Ok(Response::new(pb::ValidateConfigResponse { - valid, - reason: if valid { - String::new() - } else { - "the example accepts only its empty fixed-policy configuration".to_owned() - }, - })) - } - - async fn evaluate_http_request( - &self, - request: Request, - ) -> Result, Status> { - let sandbox_id = request - .get_ref() - .context - .as_ref() - .map(|context| context.sandbox_id.as_str()) - .filter(|value| !value.is_empty()); - self.authentication - .verify(request.metadata(), &["supervisor"], sandbox_id)?; - let request = request.into_inner(); - if request.phase != pb::SupervisorMiddlewarePhase::PreCredentials as i32 { - return Ok(Response::new(Self::deny("unsupported_phase"))); - } - if request.middleware_name != self.config.middleware_name { - return Ok(Response::new(Self::deny("middleware_context_mismatch"))); - } - let Some(context) = request.context else { - return Ok(Response::new(Self::deny("request_context_missing"))); - }; - let Some(target) = request.target else { - return Ok(Response::new(Self::deny("provider_shape_unsupported"))); - }; - let receipts: Vec<_> = request - .headers - .iter() - .filter(|header| header.name.eq_ignore_ascii_case(RECEIPT_HEADER)) - .collect(); - if receipts.is_empty() { - return Ok(Response::new(Self::deny("receipt_missing"))); - } - if receipts.len() != 1 { - return Ok(Response::new(Self::deny("receipt_malformed"))); - } - let provider_target = ProviderTarget { - scheme: target.scheme, - host: target.host, - port: target.port, - method: target.method, - path: target.path, - query: target.query, - }; - if provider_target != self.config.provider_target { - return Ok(Response::new(Self::deny("receipt_context_mismatch"))); - } - let projection = match inspect_provider_request(&request.body, &request.headers) { - Ok(projection) => projection, - Err(code) => return Ok(Response::new(Self::deny(code))), - }; - let receipt_context = ReceiptContext { - middleware_name: &self.config.middleware_name, - sandbox_id: &context.sandbox_id, - target: &provider_target, - }; - if let Err(code) = - self.receipts - .verify_header(&receipts[0].value, &projection, receipt_context) - { - return Ok(Response::new(Self::deny(code))); - } - Ok(Response::new(pb::HttpRequestResult { - decision: pb::Decision::Allow as i32, - header_mutations: vec![pb::HeaderMutation { - operation: Some(pb::header_mutation::Operation::Remove(pb::RemoveHeader { - name: RECEIPT_HEADER.to_owned(), - })), - }], - ..Default::default() - })) - } - - async fn evaluate_web_socket_session( - &self, - _request: Request>, - ) -> Result, Status> { - Err(Status::unimplemented( - "Pi admission supports HTTP requests only", - )) - } -} diff --git a/projects/research/pi-admission/middleware/src/main.rs b/projects/research/pi-admission/middleware/src/main.rs deleted file mode 100644 index 6531345f..00000000 --- a/projects/research/pi-admission/middleware/src/main.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::{env, error::Error, fs, net::SocketAddr, path::PathBuf, sync::Arc}; - -use ed25519_dalek::SigningKey; -use pi_admission::{AdmissionConfig, Middleware, ReceiptAuthority, admission_router}; -use rand::rngs::OsRng; -use tonic::transport::{Identity, Server, ServerTlsConfig}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let path = config_path()?; - let config: AdmissionConfig = serde_json::from_slice(&fs::read(path)?)?; - let config = Arc::new(config); - let receipts = Arc::new(ReceiptAuthority::new(SigningKey::generate(&mut OsRng))); - let middleware = Middleware::from_config(config.clone(), receipts.clone())?; - let certificate = fs::read(&config.tls_certificate)?; - let private_key = fs::read(&config.tls_private_key)?; - let grpc_address: SocketAddr = "0.0.0.0:50051".parse()?; - let admission_address: SocketAddr = config.listen.parse()?; - let tls = - axum_server::tls_rustls::RustlsConfig::from_pem(certificate.clone(), private_key.clone()) - .await?; - - println!("serving Pi admission HTTPS on {admission_address} and gRPC on {grpc_address}"); - let grpc = Server::builder() - .tls_config(ServerTlsConfig::new().identity(Identity::from_pem(certificate, private_key)))? - .add_service(middleware.service()) - .serve(grpc_address); - let http = axum_server::bind_rustls(admission_address, tls) - .serve(admission_router(config, receipts).into_make_service()); - tokio::try_join!( - async { - grpc.await - .map_err(|error| -> Box { Box::new(error) }) - }, - async { - http.await - .map_err(|error| -> Box { Box::new(error) }) - }, - )?; - Ok(()) -} - -fn config_path() -> Result> { - let mut arguments = env::args_os().skip(1); - if arguments.next().as_deref() != Some("--config".as_ref()) { - return Err("usage: pi-admission --config PATH".into()); - } - let path = arguments.next().ok_or("missing configuration path")?; - if arguments.next().is_some() { - return Err("unexpected argument".into()); - } - Ok(path.into()) -} diff --git a/projects/research/pi-admission/middleware/src/policy.rs b/projects/research/pi-admission/middleware/src/policy.rs deleted file mode 100644 index 1597db19..00000000 --- a/projects/research/pi-admission/middleware/src/policy.rs +++ /dev/null @@ -1,361 +0,0 @@ -use std::{collections::BTreeSet, sync::LazyLock}; - -use regex::Regex; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; - -use crate::pb; - -pub(crate) const POLICY_ID: &str = "pi-admission-fixed-regex.v1"; - -static EMAIL: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)\b[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@example\.com\b").unwrap()); -static SSN: LazyLock = - LazyLock::new(|| Regex::new(r"\b[0-9]{3}-[0-9]{2}-[0-9]{4}\b").unwrap()); - -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct ProviderTarget { - pub scheme: String, - pub host: String, - pub port: u32, - pub method: String, - pub path: String, - pub query: String, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(tag = "role", deny_unknown_fields)] -pub(crate) enum ContextEntry { - #[serde(rename = "user")] - User { text: String }, - #[serde(rename = "tool")] - Tool { tool_call_id: String, text: String }, -} - -pub(crate) type Projection = Vec; - -pub(crate) enum CandidateDecision { - Allow, - Replace(Value), - Deny(&'static str), -} - -pub(crate) fn evaluate_candidate(kind: &str, mut body: Value) -> CandidateDecision { - if contains_match(&body, &SSN) { - return CandidateDecision::Deny("ssn_detected"); - } - let result = match kind { - "user_message" => edit_message(&mut body, "user"), - "compaction_summary" => edit_message(&mut body, "compaction_summary"), - "tool_result" => edit_tool_result(&mut body), - "assistant_message" => edit_assistant(&mut body), - "provider_context" => validate_provider_context(&body), - _ => Err("admission_contract_invalid"), - }; - match result { - Err(code) => CandidateDecision::Deny(code), - Ok(false) => CandidateDecision::Allow, - Ok(true) => CandidateDecision::Replace(body), - } -} - -pub(crate) fn inspect_provider_request( - body: &[u8], - headers: &[pb::HttpHeader], -) -> Result { - let content_types: Vec<_> = headers - .iter() - .filter(|header| header.name.eq_ignore_ascii_case("content-type")) - .map(|header| header.value.trim().to_ascii_lowercase()) - .collect(); - if content_types != ["application/json"] - || headers - .iter() - .any(|header| header.name.eq_ignore_ascii_case("content-encoding")) - { - return Err("provider_shape_unsupported"); - } - let value: Value = serde_json::from_slice(body).map_err(|_| "provider_shape_unsupported")?; - if contains_match(&value, &SSN) || contains_match(&value, &EMAIL) { - return Err("entity_detected_at_egress"); - } - let object = value.as_object().ok_or("provider_shape_unsupported")?; - exact_keys( - object, - &[ - "model", - "messages", - "tools", - "tool_choice", - "temperature", - "top_p", - "max_completion_tokens", - "max_tokens", - "stream", - "stream_options", - "store", - "prompt_cache_key", - "prompt_cache_retention", - "reasoning_effort", - "reasoning", - "enable_thinking", - ], - )?; - if object.get("model").and_then(Value::as_str).is_none() - || object.get("stream") != Some(&Value::Bool(true)) - || (object.contains_key("max_tokens") == object.contains_key("max_completion_tokens")) - { - return Err("provider_shape_unsupported"); - } - let messages = object - .get("messages") - .and_then(Value::as_array) - .ok_or("provider_shape_unsupported")?; - let mut projection = Vec::new(); - for message in messages { - let message = message.as_object().ok_or("provider_shape_unsupported")?; - exact_keys( - message, - &[ - "role", - "content", - "name", - "tool_call_id", - "tool_calls", - "reasoning_content", - "reasoning", - "reasoning_text", - "reasoning_details", - ], - )?; - let role = message - .get("role") - .and_then(Value::as_str) - .ok_or("provider_shape_unsupported")?; - let content = message.get("content").ok_or("provider_shape_unsupported")?; - let text = provider_content(content)?; - match role { - "user" => { - if let Some(text) = text { - projection.push(ContextEntry::User { text }); - } - } - "tool" => { - let id = message - .get("tool_call_id") - .and_then(Value::as_str) - .ok_or("provider_shape_unsupported")?; - let text = text.ok_or("provider_shape_unsupported")?; - projection.push(ContextEntry::Tool { - tool_call_id: id.split('|').next().unwrap_or(id).to_owned(), - text, - }); - } - "system" | "developer" | "assistant" => {} - _ => return Err("provider_shape_unsupported"), - } - } - if projection.is_empty() { - return Err("provider_shape_unsupported"); - } - Ok(projection) -} - -fn edit_message(body: &mut Value, origin: &str) -> Result { - let object = shape(body, &["schema_version", "origin", "text"])?; - if object.get("schema_version").and_then(Value::as_str) != Some("openshell.pi-message.v1") - || object.get("origin").and_then(Value::as_str) != Some(origin) - { - return Err("admission_contract_invalid"); - } - replace_field(object, "text") -} - -fn edit_tool_result(body: &mut Value) -> Result { - let object = shape( - body, - &[ - "schema_version", - "tool_call_id", - "tool_name", - "content", - "is_error", - ], - )?; - if object.get("schema_version").and_then(Value::as_str) != Some("openshell.pi-tool-result.v1") - || object.get("tool_call_id").and_then(Value::as_str).is_none() - || object.get("tool_name").and_then(Value::as_str).is_none() - || object.get("is_error").and_then(Value::as_bool).is_none() - { - return Err("admission_contract_invalid"); - } - let blocks = object - .get_mut("content") - .and_then(Value::as_array_mut) - .ok_or("admission_contract_invalid")?; - let mut changed = false; - for block in blocks { - let block = shape(block, &["type", "text"])?; - if block.get("type").and_then(Value::as_str) != Some("text") { - return Err("admission_contract_invalid"); - } - changed |= replace_field(block, "text")?; - } - Ok(changed) -} - -fn edit_assistant(body: &mut Value) -> Result { - let object = shape(body, &["schema_version", "text", "tool_calls", "thinking"])?; - if object.get("schema_version").and_then(Value::as_str) - != Some("openshell.pi-assistant-message.v1") - { - return Err("admission_contract_invalid"); - } - if contains_match( - object - .get("tool_calls") - .ok_or("admission_contract_invalid")?, - &EMAIL, - ) { - return Err("immutable_content_detected"); - } - let mut changed = replace_field(object, "text")?; - let thinking = object - .get_mut("thinking") - .and_then(Value::as_array_mut) - .ok_or("admission_contract_invalid")?; - for block in thinking { - let block = shape(block, &["text", "signature"])?; - let redactable = block - .get("text") - .and_then(Value::as_str) - .is_some_and(|text| EMAIL.is_match(text)); - let signature = block.get("signature").ok_or("admission_contract_invalid")?; - let editable = signature.is_null() - || matches!( - signature.as_str(), - Some("reasoning" | "reasoning_content" | "reasoning_text") - ); - if redactable && !editable { - return Err("immutable_content_detected"); - } - if editable { - changed |= replace_field(block, "text")?; - } - } - Ok(changed) -} - -fn validate_provider_context(body: &Value) -> Result { - let object = body.as_object().ok_or("admission_contract_invalid")?; - exact_keys(object, &["schema_version", "entries"])?; - if object.get("schema_version").and_then(Value::as_str) - != Some("openshell.pi-provider-context.v1") - { - return Err("admission_contract_invalid"); - } - let entries: Projection = serde_json::from_value( - object - .get("entries") - .cloned() - .ok_or("admission_contract_invalid")?, - ) - .map_err(|_| "admission_contract_invalid")?; - if entries.is_empty() { - return Err("admission_contract_invalid"); - } - if contains_match(body, &EMAIL) { - return Err("email_detected_at_receipt"); - } - Ok(false) -} - -fn provider_content(value: &Value) -> Result, &'static str> { - if value.is_null() { - return Ok(None); - } - if let Some(text) = value.as_str() { - return Ok(Some(text.to_owned())); - } - let blocks = value.as_array().ok_or("provider_shape_unsupported")?; - let mut text = Vec::new(); - for block in blocks { - let block = block.as_object().ok_or("provider_shape_unsupported")?; - exact_keys(block, &["type", "text", "cache_control"])?; - if block.get("type").and_then(Value::as_str) != Some("text") { - return Err("provider_shape_unsupported"); - } - text.push( - block - .get("text") - .and_then(Value::as_str) - .ok_or("provider_shape_unsupported")?, - ); - } - Ok(Some(text.join("\n"))) -} - -fn replace_field(object: &mut Map, key: &str) -> Result { - let value = object - .get_mut(key) - .and_then(|value| value.as_str()) - .ok_or("admission_contract_invalid")?; - let replacement = EMAIL.replace_all(value, "[EMAIL]"); - if replacement == value { - return Ok(false); - } - *object.get_mut(key).unwrap() = Value::String(replacement.into_owned()); - Ok(true) -} - -fn shape<'a>( - value: &'a mut Value, - keys: &[&str], -) -> Result<&'a mut Map, &'static str> { - let object = value.as_object_mut().ok_or("admission_contract_invalid")?; - exact_keys(object, keys)?; - Ok(object) -} - -fn exact_keys(object: &Map, allowed: &[&str]) -> Result<(), &'static str> { - let allowed: BTreeSet<_> = allowed.iter().copied().collect(); - if object.keys().any(|key| !allowed.contains(key.as_str())) { - return Err("provider_shape_unsupported"); - } - Ok(()) -} - -fn contains_match(value: &Value, pattern: &Regex) -> bool { - match value { - Value::String(text) => pattern.is_match(text), - Value::Array(values) => values.iter().any(|value| contains_match(value, pattern)), - Value::Object(values) => values.values().any(|value| contains_match(value, pattern)), - _ => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn egress_allows_approved_content_and_rejects_decoded_entities() { - let headers = [pb::HttpHeader { - name: "content-type".to_owned(), - value: "application/json".to_owned(), - }]; - let allowed = br#"{"model":"demo","messages":[{"role":"user","content":"[EMAIL]"}],"max_tokens":10,"stream":true}"#; - assert_eq!( - inspect_provider_request(allowed, &headers).unwrap(), - vec![ContextEntry::User { - text: "[EMAIL]".to_owned() - }] - ); - let escaped = br#"{"model":"demo","messages":[{"role":"user","content":"alice\u0040example.com"}],"max_tokens":10,"stream":true}"#; - assert_eq!( - inspect_provider_request(escaped, &headers), - Err("entity_detected_at_egress") - ); - } -} diff --git a/projects/research/pi-admission/middleware/src/receipt.rs b/projects/research/pi-admission/middleware/src/receipt.rs deleted file mode 100644 index efee953b..00000000 --- a/projects/research/pi-admission/middleware/src/receipt.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use base64::{Engine, engine::general_purpose}; -use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; -use rand::{RngCore, rngs::OsRng}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use crate::policy::{POLICY_ID, Projection, ProviderTarget}; - -const LIFETIME_SECONDS: u64 = 300; -const CLOCK_SKEW_SECONDS: u64 = 5; -const MAX_RECEIPT_BYTES: usize = 8 * 1024; - -#[derive(Clone)] -pub struct ReceiptAuthority { - signing_key: SigningKey, - verifying_key: VerifyingKey, - key_id: String, -} - -pub(crate) struct ReceiptContext<'a> { - pub middleware_name: &'a str, - pub sandbox_id: &'a str, - pub target: &'a ProviderTarget, -} - -#[derive(Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Claims { - receipt_version: String, - canonicalization_version: String, - harness: String, - harness_version: String, - harness_schema: String, - hook: String, - middleware_binding: String, - policy_identity: String, - sandbox_id: String, - session_id: String, - submission_id: String, - receipt_id: String, - provider_adapter_schema: String, - host: String, - port: u32, - subject_kind: String, - subject_hash: String, - entry_count: usize, - issued_at: u64, - expires_at: u64, - key_id: String, -} - -impl ReceiptAuthority { - pub fn new(signing_key: SigningKey) -> Self { - let verifying_key = signing_key.verifying_key(); - let key_id = hex(&Sha256::digest(verifying_key.as_bytes()))[..16].to_owned(); - Self { - signing_key, - verifying_key, - key_id, - } - } - - pub(crate) fn issue_header( - &self, - projection: &Projection, - context: ReceiptContext<'_>, - session_id: &str, - submission_id: &str, - ) -> Result { - self.issue_header_at(projection, context, session_id, submission_id, now()?) - } - - fn issue_header_at( - &self, - projection: &Projection, - context: ReceiptContext<'_>, - session_id: &str, - submission_id: &str, - issued_at: u64, - ) -> Result { - let mut identifier = [0_u8; 16]; - OsRng.fill_bytes(&mut identifier); - let claims = Claims { - receipt_version: "pi-admission-receipt.v1".to_owned(), - canonicalization_version: "canonical-json.v1".to_owned(), - harness: "pi".to_owned(), - harness_version: "sdk-v1".to_owned(), - harness_schema: "openshell.pi-provider-context.v1".to_owned(), - hook: "provider_context".to_owned(), - middleware_binding: context.middleware_name.to_owned(), - policy_identity: POLICY_ID.to_owned(), - sandbox_id: context.sandbox_id.to_owned(), - session_id: session_id.to_owned(), - submission_id: submission_id.to_owned(), - receipt_id: hex(&identifier), - provider_adapter_schema: "openai.request.v1".to_owned(), - host: context.target.host.clone(), - port: context.target.port, - subject_kind: "context".to_owned(), - subject_hash: subject(projection)?, - entry_count: projection.len(), - issued_at, - expires_at: issued_at + LIFETIME_SECONDS, - key_id: self.key_id.clone(), - }; - let payload = serde_json::to_vec(&claims).map_err(|_| "receipt_issuance_failed")?; - let signature = self.signing_key.sign(&payload); - let token = format!( - "pr1.{}.{}", - general_purpose::URL_SAFE_NO_PAD.encode(payload), - general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes()) - ); - Ok(general_purpose::URL_SAFE.encode(token.as_bytes())) - } - - pub(crate) fn verify_header( - &self, - header: &str, - projection: &Projection, - context: ReceiptContext<'_>, - ) -> Result<(), &'static str> { - self.verify_header_at(header, projection, context, now()?) - } - - fn verify_header_at( - &self, - header: &str, - projection: &Projection, - context: ReceiptContext<'_>, - current: u64, - ) -> Result<(), &'static str> { - if header.len() > MAX_RECEIPT_BYTES * 4 / 3 + 4 { - return Err("receipt_malformed"); - } - let token = general_purpose::URL_SAFE - .decode(header) - .map_err(|_| "receipt_malformed")?; - if token.len() > MAX_RECEIPT_BYTES { - return Err("receipt_malformed"); - } - let token = std::str::from_utf8(&token).map_err(|_| "receipt_malformed")?; - let mut parts = token.split('.'); - if parts.next() != Some("pr1") { - return Err("receipt_malformed"); - } - let payload = general_purpose::URL_SAFE_NO_PAD - .decode(parts.next().ok_or("receipt_malformed")?) - .map_err(|_| "receipt_malformed")?; - let signature = general_purpose::URL_SAFE_NO_PAD - .decode(parts.next().ok_or("receipt_malformed")?) - .map_err(|_| "receipt_malformed")?; - if parts.next().is_some() { - return Err("receipt_malformed"); - } - let signature = - ed25519_dalek::Signature::from_slice(&signature).map_err(|_| "receipt_malformed")?; - self.verifying_key - .verify(&payload, &signature) - .map_err(|_| "receipt_signature_invalid")?; - let claims: Claims = serde_json::from_slice(&payload).map_err(|_| "receipt_malformed")?; - if serde_json::to_vec(&claims).map_err(|_| "receipt_malformed")? != payload { - return Err("receipt_malformed"); - } - if claims.issued_at > current + CLOCK_SKEW_SECONDS { - return Err("receipt_not_yet_valid"); - } - if claims.expires_at <= current || claims.expires_at <= claims.issued_at { - return Err("receipt_expired"); - } - if claims.receipt_version != "pi-admission-receipt.v1" - || claims.canonicalization_version != "canonical-json.v1" - || claims.harness != "pi" - || claims.harness_version != "sdk-v1" - || claims.harness_schema != "openshell.pi-provider-context.v1" - || claims.hook != "provider_context" - || claims.middleware_binding != context.middleware_name - || claims.policy_identity != POLICY_ID - || claims.sandbox_id != context.sandbox_id - || claims.provider_adapter_schema != "openai.request.v1" - || claims.host != context.target.host - || claims.port != context.target.port - || claims.subject_kind != "context" - || claims.key_id != self.key_id - { - return Err("receipt_context_mismatch"); - } - if claims.entry_count != projection.len() || claims.subject_hash != subject(projection)? { - return Err("receipt_content_mismatch"); - } - Ok(()) - } -} - -fn subject(projection: &Projection) -> Result { - let bytes = serde_json::to_vec(projection).map_err(|_| "receipt_content_invalid")?; - Ok(hex(&Sha256::digest(bytes))) -} - -fn now() -> Result { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .map_err(|_| "clock_invalid") -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::policy::ContextEntry; - - fn target() -> ProviderTarget { - ProviderTarget { - scheme: "https".to_owned(), - host: "api.example.test".to_owned(), - port: 443, - method: "POST".to_owned(), - path: "/v1/chat/completions".to_owned(), - query: String::new(), - } - } - - #[test] - fn receipt_binds_content_destination_and_sandbox() { - let authority = ReceiptAuthority::new(SigningKey::from_bytes(&[7; 32])); - let target = target(); - let projection = vec![ContextEntry::User { - text: "approved".to_owned(), - }]; - let context = ReceiptContext { - middleware_name: "pi-admission", - sandbox_id: "sandbox-1", - target: &target, - }; - let receipt = authority - .issue_header(&projection, context, "session", "submission") - .unwrap(); - let context = ReceiptContext { - middleware_name: "pi-admission", - sandbox_id: "sandbox-1", - target: &target, - }; - assert!( - authority - .verify_header(&receipt, &projection, context) - .is_ok() - ); - let mut invalid = general_purpose::URL_SAFE.decode(&receipt).unwrap(); - let start = invalid.iter().rposition(|byte| *byte == b'.').unwrap() + 1; - invalid[start] = if invalid[start] == b'A' { b'B' } else { b'A' }; - let invalid = general_purpose::URL_SAFE.encode(invalid); - let context = ReceiptContext { - middleware_name: "pi-admission", - sandbox_id: "sandbox-1", - target: &target, - }; - assert_eq!( - authority.verify_header(&invalid, &projection, context), - Err("receipt_signature_invalid") - ); - let changed = vec![ContextEntry::User { - text: "changed".to_owned(), - }]; - let context = ReceiptContext { - middleware_name: "pi-admission", - sandbox_id: "sandbox-1", - target: &target, - }; - assert_eq!( - authority.verify_header(&receipt, &changed, context), - Err("receipt_content_mismatch") - ); - - let wrong_target = ProviderTarget { - host: "other.example.test".to_owned(), - ..target.clone() - }; - let context = ReceiptContext { - middleware_name: "pi-admission", - sandbox_id: "sandbox-1", - target: &wrong_target, - }; - assert_eq!( - authority.verify_header(&receipt, &projection, context), - Err("receipt_context_mismatch") - ); - let context = ReceiptContext { - middleware_name: "pi-admission", - sandbox_id: "sandbox-2", - target: &target, - }; - assert_eq!( - authority.verify_header(&receipt, &projection, context), - Err("receipt_context_mismatch") - ); - } -} diff --git a/projects/research/pi-admission/policy.yaml b/projects/research/pi-admission/policy.yaml index 94a1d654..88669c8a 100644 --- a/projects/research/pi-admission/policy.yaml +++ b/projects/research/pi-admission/policy.yaml @@ -26,26 +26,12 @@ network_policies: - { path: /usr/bin/node } - { path: /usr/local/bin/node } - admission: - name: Authenticated admission API - endpoints: - - host: host.docker.internal # prepare replaces this with PI_ADMISSION_HOST. - port: 5443 - protocol: rest - enforcement: enforce - rules: - - allow: - method: POST - path: /v1/admission - binaries: - - { path: /usr/local/bin/node } - network_middlewares: - pi_admission: - name: Verify admitted Pi provider context - middleware: pi-admission - order: 0 - config: {} + network_redaction: + name: Redact fake API keys at egress only + middleware: openshell/regex + config: + mode: redact on_error: fail_closed endpoints: include: diff --git a/projects/research/pi-admission/prepare.py b/projects/research/pi-admission/prepare.py index eed7d4f9..a76d706a 100644 --- a/projects/research/pi-admission/prepare.py +++ b/projects/research/pi-admission/prepare.py @@ -1,43 +1,26 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Prepare one host-owned demo configuration. Never run inside the sandbox.""" +"""Prepare the image, model profile, and built-in network-redaction policy.""" from __future__ import annotations import argparse -import ipaddress import json import os -import secrets import shutil -import ssl -import sys -from datetime import UTC, datetime, timedelta -from http.client import HTTPSConnection from pathlib import Path from urllib.parse import urlparse -import jwt import yaml -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec, ed25519 -from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID def prepare( example: Path, state: Path, - host: str, - gateway_public_key: Path, - gateway_issuer: str, model_selection: str = "", ) -> None: - """Keep keys outside the image; copy only the public CA and explicit demo files.""" - endpoint = urlparse(f"https://{host}:5443") - if endpoint.hostname != host or endpoint.port != 5443 or endpoint.path: - raise ValueError("Use a DNS hostname or IPv4 address, without a URL or port") + """Stage public configuration; provider credentials remain host-owned.""" catalog, selection, base_url = select_model( example / "models.json", model_selection ) @@ -52,98 +35,38 @@ def prepare( raise ValueError( "The model must use an HTTPS endpoint without credentials or query" ) - if target.hostname == host: - raise ValueError("Model and admission endpoints must be separate") - public_key = serialization.load_pem_public_key(gateway_public_key.read_bytes()) - if not isinstance(public_key, ed25519.Ed25519PublicKey): - raise ValueError("Provide the gateway's Ed25519 public signing key") os.umask(0o077) state.mkdir(parents=True, exist_ok=True) - tls = state / "tls" - certificate = tls / "server/tls.crt" - if ( - not certificate.exists() - or (state / "service-host").read_text() != host - or x509.load_pem_x509_certificate(certificate.read_bytes()).not_valid_after_utc - <= datetime.now(UTC) - ): - _create_certificates(tls, host) - print("Service TLS created: install tls/ca.crt in the gateway's trust config.") - (state / "service-host").write_text(host) model_path = target.path.rstrip("/") + "/chat/completions" policy = yaml.safe_load((example / "policy.yaml").read_text()) model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] model_endpoint.update(host=target.hostname, port=target.port or 443) model_endpoint["rules"][0]["allow"]["path"] = model_path - policy["network_policies"]["admission"]["endpoints"][0]["host"] = host - binding = policy["network_middlewares"]["pi_admission"] - binding["endpoints"]["include"] = [target.hostname] + policy["network_middlewares"]["network_redaction"]["endpoints"]["include"] = [ + target.hostname + ] (state / "policy.yaml").write_text(yaml.safe_dump(policy, sort_keys=False)) - for name, provider_host, port, variable in [ - ("model", target.hostname, target.port or 443, "PI_MODEL_API_KEY"), - ("admission", host, 5443, "PI_ADMISSION_TOKEN"), - ]: - profile = { - "id": f"pi-admission-{name}", - "display_name": f"Pi example {name}", - "category": "inference" if name == "model" else "other", - "credentials": [ - {"name": "token", "env_vars": [variable], "required": True} - ], - "discovery": {"credentials": ["token"]}, - "endpoints": [ - { - "host": provider_host, - "port": port, - "protocol": "rest", - "access": "read-write", - "enforcement": "enforce", - } - ], - "binaries": ["/usr/local/bin/node"], - } - (state / f"{name}-provider.yaml").write_text(yaml.safe_dump(profile)) - config_path = state / "admission.json" - token = ( - json.loads(config_path.read_text())["bearer_token"] - if config_path.exists() - else secrets.token_urlsafe(32) - ) - audience = "urn:openshell:extension:middleware:pi-admission" - config = { - "listen": "0.0.0.0:5443", - "tls_certificate": str(tls / "server/tls.crt"), - "tls_private_key": str(tls / "server/tls.key"), - "gateway_public_key": str(gateway_public_key.resolve()), - "gateway_issuer": gateway_issuer, - "gateway_audience": audience, - "middleware_name": "pi-admission", - "bearer_token": token, - "sandbox_id_file": str(state / "sandbox-id"), - "provider_target": { - "scheme": "https", - "host": target.hostname, - "port": target.port or 443, - "method": "POST", - "path": model_path, - "query": "", - }, + profile = { + "id": "pi-admission-model", + "display_name": "Pi example model", + "category": "inference", + "credentials": [ + {"name": "token", "env_vars": ["PI_MODEL_API_KEY"], "required": True} + ], + "discovery": {"credentials": ["token"]}, + "endpoints": [ + { + "host": target.hostname, + "port": target.port or 443, + "protocol": "rest", + "access": "read-write", + "enforcement": "enforce", + } + ], + "binaries": ["/usr/local/bin/node"], } - config_path.write_text(json.dumps(config, indent=2) + "\n") - # JSON string quoting is also valid for these TOML basic string values. - quote = json.dumps - registration = f"""[[openshell.supervisor.middleware]] -name = "pi-admission" -grpc_endpoint = "https://{host}:50051" -tls_ca_cert_path = {quote(str(tls / "ca.crt"))} -audience = "{audience}" -max_payload_bytes = 4194304 -timeout = "10s" -""" - (state / "middleware.toml").write_text(registration) + (state / "model-provider.yaml").write_text(yaml.safe_dump(profile)) image = state / "image" - # Recreate only this generated build context, so removed source/config files - # cannot survive a subsequent prepare. Host keys and runtime state stay put. if image.exists(): shutil.rmtree(image) image.mkdir() @@ -154,7 +77,6 @@ def prepare( (image / "model-selection.json").write_text(json.dumps(selection) + "\n") shutil.copyfile(example / "sandbox/Dockerfile", image / "Dockerfile") shutil.copytree(example / "workspace", image / "workspace") - shutil.copyfile(tls / "ca.crt", image / "admission-ca.crt") print(f"Selected model: {selection['provider']}/{selection['id']}") @@ -203,144 +125,13 @@ def select_model( ) -def _discover_gateway(gateway: dict[str, str]) -> tuple[bytes, str]: - """Use the CLI's registered endpoint and existing client TLS, never new keys.""" - endpoint = urlparse(gateway["endpoint"]) - name = gateway["name"] - if endpoint.scheme != "https" or not endpoint.hostname or gateway["auth"] != "mtls": - raise ValueError("This demo requires a registered HTTPS/mTLS gateway") - if not name or Path(name).name != name or name in (".", ".."): - raise ValueError("Invalid gateway name") - config = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - tls = config / "openshell/gateways" / name / "mtls" - context = ssl.create_default_context(cafile=str(tls / "ca.crt")) - # OpenShell's generated certificates omit extensions required by Python 3.13's - # strict X.509 mode. Retain CA/signature, expiry and hostname verification. - context.verify_flags &= ~ssl.VERIFY_X509_STRICT - context.load_cert_chain(tls / "tls.crt", tls / "tls.key") - connection = HTTPSConnection( - endpoint.hostname, endpoint.port, context=context, timeout=10 - ) - try: - print(f"Discovering gateway identity from {gateway['endpoint']}") - connection.request("GET", "/.well-known/openid-configuration") - response = connection.getresponse() - if response.status != 200: - raise ValueError(f"Gateway discovery returned HTTP {response.status}") - discovery = json.load(response) - issuer = discovery["issuer"] - if not isinstance(issuer, str) or not issuer: - raise ValueError("Gateway discovery must provide a nonempty issuer") - jwks = urlparse(discovery["jwks_uri"]) - if (jwks.scheme, jwks.netloc) != (endpoint.scheme, endpoint.netloc): - raise ValueError( - "Gateway signing keys must come from the same HTTPS origin" - ) - connection.request("GET", jwks.path + (f"?{jwks.query}" if jwks.query else "")) - response = connection.getresponse() - if response.status != 200: - raise ValueError( - f"Gateway signing-key discovery returned HTTP {response.status}" - ) - keys = json.load(response)["keys"] - if len(keys) != 1: - raise ValueError("This demo expects one gateway signing key") - key = jwt.PyJWK.from_dict(keys[0]).key - if not isinstance(key, ed25519.Ed25519PublicKey): - raise ValueError("Gateway must publish an Ed25519 public signing key") - return key.public_bytes( - serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo - ), issuer - finally: - connection.close() - - -def _create_certificates(tls: Path, host: str) -> None: - now = datetime.now(UTC) - ca_key = ec.generate_private_key(ec.SECP256R1()) - ca_name = x509.Name( - [x509.NameAttribute(NameOID.COMMON_NAME, "Pi admission demo CA")] - ) - ca = ( - x509.CertificateBuilder() - .subject_name(ca_name) - .issuer_name(ca_name) - .public_key(ca_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - timedelta(minutes=5)) - .not_valid_after(now + timedelta(days=30)) - .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) - .sign(ca_key, hashes.SHA256()) - ) - tls.mkdir(exist_ok=True) - (tls / "ca.crt").write_bytes(ca.public_bytes(serialization.Encoding.PEM)) - # The CA key is not needed again; each setup has a 30-day local trust bundle. - key = ec.generate_private_key(ec.SECP256R1()) - certificate = ( - x509.CertificateBuilder() - .subject_name( - x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "pi-admission-service")]) - ) - .issuer_name(ca_name) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - timedelta(minutes=5)) - .not_valid_after(now + timedelta(days=30)) - .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), - critical=False, - ) - .add_extension( - x509.SubjectAlternativeName( - [x509.DNSName("localhost"), _service_name(host)] - ), - critical=False, - ) - .sign(ca_key, hashes.SHA256()) - ) - directory = tls / "server" - directory.mkdir(exist_ok=True) - (directory / "tls.crt").write_bytes( - certificate.public_bytes(serialization.Encoding.PEM) - ) - (directory / "tls.key").write_bytes( - key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ) - ) - - -def _service_name(host: str) -> x509.GeneralName: - try: - return x509.IPAddress(ipaddress.ip_address(host)) - except ValueError: - return x509.DNSName(host) - - if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--state", type=Path, required=True) - parser.add_argument("--host", required=True) - parser.add_argument("--gateway", required=True) parser.add_argument("--model", default="") args = parser.parse_args() - gateways = json.load(sys.stdin) - gateway = next((item for item in gateways if item["name"] == args.gateway), None) - if gateway is None: - parser.error("Gateway is not registered; use openshell gateway add first") - public_key, issuer = _discover_gateway(gateway) - os.umask(0o077) - args.state.mkdir(parents=True, exist_ok=True) - public_path = args.state.resolve() / "gateway-public.pem" - public_path.write_bytes(public_key) prepare( Path(__file__).resolve().parent, args.state.resolve(), - args.host, - public_path, - issuer, args.model, ) diff --git a/projects/research/pi-admission/pyproject.toml b/projects/research/pi-admission/pyproject.toml index c67a673c..7d46c111 100644 --- a/projects/research/pi-admission/pyproject.toml +++ b/projects/research/pi-admission/pyproject.toml @@ -6,8 +6,6 @@ requires-python = ">=3.11" license = "Apache-2.0" license-files = ["LICENSE"] dependencies = [ - "cryptography>=50,<51", - "pyjwt[crypto]>=2.10,<3", "pyyaml>=6,<7", ] diff --git a/projects/research/pi-admission/sandbox/Dockerfile b/projects/research/pi-admission/sandbox/Dockerfile index 099e980c..eaa4bf90 100644 --- a/projects/research/pi-admission/sandbox/Dockerfile +++ b/projects/research/pi-admission/sandbox/Dockerfile @@ -8,8 +8,6 @@ RUN apt-get update \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ && useradd --create-home --uid 1001 sandbox \ && rm -rf /var/lib/apt/lists/* -COPY admission-ca.crt /usr/local/share/ca-certificates/admission-ca.crt -RUN update-ca-certificates WORKDIR /app COPY pi-harness/package.json pi-harness/package-lock.json ./ RUN npm ci --ignore-scripts --no-audit --no-fund diff --git a/projects/research/pi-admission/uv.lock b/projects/research/pi-admission/uv.lock index 4bb3b2b6..d5c7ff3a 100644 --- a/projects/research/pi-admission/uv.lock +++ b/projects/research/pi-admission/uv.lock @@ -2,167 +2,11 @@ version = 1 revision = 3 requires-python = ">=3.11" -[[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 = "cryptography" -version = "50.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, - { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, - { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, - { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, - { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, - { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, - { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, - { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, - { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, - { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, - { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, - { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, - { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, - { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, - { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, - { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, - { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, - { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, - { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, - { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, - { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, - { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, - { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, -] - [[package]] name = "pi-admission-example" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "cryptography" }, - { name = "pyjwt", extra = ["crypto"] }, { name = "pyyaml" }, ] @@ -172,38 +16,11 @@ dev = [ ] [package.metadata] -requires-dist = [ - { name = "cryptography", specifier = ">=50,<51" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, - { name = "pyyaml", specifier = ">=6,<7" }, -] +requires-dist = [{ name = "pyyaml", specifier = ">=6,<7" }] [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.12,<1" }] -[[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 = "pyjwt" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - [[package]] name = "pyyaml" version = "6.0.3" From 04829d583618ae31271866701a72f5640cb12388 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 21 Sep 2026 14:41:24 +0000 Subject: [PATCH 12/14] docs(pi-admission): explain the local redaction comparison --- projects/README.md | 4 +- projects/research/pi-admission/README.md | 363 +++++++++-------------- 2 files changed, 142 insertions(+), 225 deletions(-) diff --git a/projects/README.md b/projects/README.md index 0dbbe32b..3141e2e5 100644 --- a/projects/README.md +++ b/projects/README.md @@ -19,8 +19,8 @@ including how its location determines the automated review kind. ## Research -- `pi-admission`: Pi admission spike demonstrating redaction before history - writes and signed approval receipts enforced at OpenShell network egress. +- `pi-admission`: Pi comparison showing why network-only redaction is + insufficient when the original content remains in saved agent history. - `long-horizon-agent-evals`: Persistent agent experiments over configurable time horizons and repeated parallel attempts, starting with GitHub policy review. diff --git a/projects/research/pi-admission/README.md b/projects/research/pi-admission/README.md index 31540015..70c5eac9 100644 --- a/projects/research/pi-admission/README.md +++ b/projects/research/pi-admission/README.md @@ -1,298 +1,215 @@ -# Minimal Pi admission spike +# Pi local-admission comparison -This research spike shows why content policy must integrate at the agent -harness—not only at network egress. It uses unmodified Pi libraries for a -controlled coding session in OpenShell with two boundaries: +This research example demonstrates one point: redaction at network egress is +not enough when the original content remains in the agent's saved session. ```text -draft -> admission HTTPS -> Pi history and JSONL -> provider request - receipt ^ | - +-- OpenShell middleware -``` - -The launcher admits a complete user, assistant, or tool-result candidate before -publishing it. The external Rust process also signs the user/tool projection for -each model call; pre-credentials middleware checks that receipt against the -actual request before OpenShell supplies the provider credential. +Admission off: + content ------------------> history -> built-in regex -> model + raw -The intentionally synthetic fixed policy is: +Admission on: + content -> local redaction -> history -> built-in regex -> model + redacted +``` -| Match | Result | -| --- | --- | -| address ending in `@example.com` | replace with `[EMAIL]` | -| `NNN-NN-NNNN` | deny | +Both modes use one sandbox, the same controlled Pi launcher, model, native +tools, and OpenShell policy. The baseline is **not** a separate stock Pi CLI; +it is this launcher's controlled publication loop with local admission disabled. +Every launch starts a fresh JSONL session and prints its mode and transcript +path. -These regexes are teaching aids, not production DLP. +The only synthetic policy is: -## Scope +```text +match: sk-[A-Za-z0-9_-]{16,} +replacement: [REDACTED] +``` -The launcher keeps Pi's native TUI, JSONL sessions, reasoning controls, and -native `read`, `bash`, `edit`, and `write` tools. Tool calls run sequentially. -The complete assistant/tool-result batch is admitted before it is published. -Tool side effects are not transactional and may exist even when a result is -denied. +The local rule intentionally matches OpenShell's built-in fake API-key rule. +This is a focused example, not a configurable policy framework or a claim of +general DLP coverage. Use only invented values. -Edit results show admitted text rather than Pi's file-derived diff preview, -which would read content outside the admission boundary. +| Mode | Saved history | Outgoing request | +| --- | --- | --- | +| Admission off | Original fake key remains | Built-in regex redacts it | +| Admission on | `[REDACTED]` replaces it | Already redacted locally | -Only explicit `/compact` is supported. Automatic compaction, retries, queued -prompts, project instructions, skills, resume/import/branching, model switching, -images, extensions, and direct `!` shell commands are disabled. A prompt entered -while Pi is working is rejected rather than retained. At context exhaustion, -run `/compact` yourself. +## Preserved Pi behavior and boundaries -The sample workspace contains `counter.js` and one Node test so a model can -inspect, edit, and test real code without extra project scaffolding. +The launcher retains Pi's native TUI and JSONL sessions, native `read`, `bash`, +`edit`, and `write` tools, supported reasoning/replay metadata, thinking +controls, usage accounting, cancellation, and explicit `/compact`. -## Run +Its controlled loop admits complete user messages, assistant output, tool +results, and manual compaction summaries before publication when admission is +on. It preserves text-block boundaries and native metadata. A transformation +that would change signed text/reasoning or tool-call semantics is rejected +instead of corrupting replay metadata or executing a modified call. Partial +streaming output and incomplete tool batches are not published. Native tool +execution is unchanged, but tool side effects are nontransactional and may +remain after a later rejection or cancellation. Unchecked tool details and edit +previews remain excluded. -You need an existing HTTPS/mTLS OpenShell gateway registered in your CLI, access -to its configuration and restart procedure, Bash, Python 3.11+, uv 0.11+, -Rust 1.90+ with native build tools, and Docker. OpenShell `0.0.116` was tested; -other releases must support the same middleware contract. Node 22 is needed -only for local harness development; the demo image includes it. +Automatic compaction, retries, queued prompts, project instructions, skills, +resume/import/branching, model switching, images, extensions, and direct `!` +commands are disabled because those history-writing paths do not have the same +pre-publication boundary. At context exhaustion, run `/compact` explicitly. -Use your own model provider: it must support HTTPS, streaming OpenAI-compatible -Chat Completions, text input, tool calling, and API-key authentication. This -spike does not support OAuth, custom authentication headers, or every Pi API. -Model calls, including the verification, may incur provider charges. +## Prerequisites and configuration -### 1. Configure and prepare +You need an existing HTTPS/mTLS OpenShell gateway registered in the CLI, Bash, +Python 3.11+, uv 0.11+, Docker, and a provider compatible with streaming, +text-only OpenAI Chat Completions and API-key authentication. OpenShell +`0.0.116` was tested. Node 22 is needed only for host-side harness development; +the image includes it. Model calls can incur provider charges. -Run these commands from `projects/research/pi-admission/`: +From `projects/research/pi-admission/`: ```sh cp .env.example .env cp models.json.example models.json ``` -Edit `models.json` to describe your provider and model using Pi's native catalog -format. Set the provider name, `baseUrl`, model `id`, limits, and compatibility -settings for your endpoint; keep `api: "openai-completions"` and text-only input. -The supplied OpenRouter/GLM configuration is an example, not a requirement. -Do not put API keys in this file: it is copied into the sandbox image. - -Set these values in `.env`: +Edit `models.json` using Pi's native catalog format. Set the provider `baseUrl`, +model `id`, limits, and compatibility values; retain +`api: "openai-completions"` and text-only input. Do not put credentials there. -| Variable | Your value | -| --- | --- | -| `OPENSHELL_GATEWAY` | The gateway name shown by `openshell gateway list` | -| `PI_ADMISSION_HOST` | DNS hostname or IPv4 address of the machine running `serve`, without a scheme or port | -| `PI_MODEL_API_KEY` | The API key for the selected model provider | -| `PI_MODEL` | Only when the catalog has multiple models: `provider/model-id` | - -The gateway and sandbox supervisor must reach the service on TCP **50051**; -Pi inside the sandbox uses TCP **5443**. Choose a hostname/address reachable -from both gateway and sandbox; `localhost` -inside a sandbox points to the sandbox, not your host. Docker-specific hostnames -are suitable only if they also resolve from the gateway. Allow these connections -through the host firewall. +Set `OPENSHELL_GATEWAY` and `PI_MODEL_API_KEY` in `.env`. Set `PI_MODEL` only +when the catalog contains multiple models. ```sh ./demo.sh prepare +./demo.sh setup ``` -Preparation discovers the gateway identity using your CLI's existing mTLS -credentials, creates a 30-day service certificate, and builds the local -`pi-admission:local` image. Private generated state stays in `.workspaces/`. - -### 2. Start and register the service - -In one terminal, start the service and leave it running: - -```sh -./demo.sh serve -``` - -In a second terminal, from the same project directory, print the registration: - -```sh -./demo.sh registration -``` - -This command reads `.workspaces/middleware.toml`, created by `prepare`, and -prints it. It does **not** write to the gateway configuration, register the -middleware, or restart anything. +Preparation selects the model, renders the model provider and policy, and +builds `pi-admission:local`. Setup creates one model provider and one sandbox. +There is no admission service, certificate, token, identity binding, custom +middleware registration, or gateway restart. -Find the TOML configuration actually loaded by your gateway process. Its path -depends on how the gateway was installed: check its service/startup configuration -or ask its operator. This is the **server's configuration**, not the CLI's local -gateway credentials. Do not assume a file named `gateway.toml` in the current -directory is the right one. +## Run the comparison -Back up that file, then add the printed `[[openshell.supervisor.middleware]]` -entry. For a gateway configuration accessible on this machine, the command is: +Use a different fresh fake key in each run. Reuse can let a later conversation +recover a value from an earlier transcript in the shared sandbox and obscure +what the comparison measures. Do not place either value in workspace files. -```sh -# Replace this placeholder with the existing, active gateway config path. -./demo.sh registration >> /path/to/gateway.toml -``` - -If the file requires administrator permissions, use this **instead**: +### Admission off ```sh -./demo.sh registration | sudo tee -a /path/to/gateway.toml >/dev/null +./demo.sh launch --admission off ``` -Both commands **append**. Run only one, and only when no middleware entry named -`pi-admission` already exists. On subsequent runs, edit the existing entry rather -than appending a duplicate. Do not use `>`: it would overwrite the gateway's -other settings. +1. Note the printed transcript path. +2. Enter a new value matching the synthetic pattern, for example in a prompt + asking only for acknowledgement. +3. Inspect the stored user message in the printed JSONL file and confirm the + original remains. +4. Confirm a built-in regex finding in OpenShell's sandbox logs: -For a remote or containerized gateway, install the entry in that deployment's -configuration instead of appending to an unrelated local file. Copy/mount the -**public** `.workspaces/tls/ca.crt` there and adjust `tls_ca_cert_path` in the entry -to a path readable by the gateway process. Do not copy the service's private key. + ```sh + openshell --gateway YOUR_GATEWAY logs pi-admission --source sandbox --since 5m + ``` -Restart the gateway using the procedure for your installation, while `serve` -is running. Both the gateway and each sandbox connect to the service at startup. -This restart may briefly affect other gateway users. +5. Copy the exact transcript path printed by the launcher, substitute it for + `PASTE_ACTUAL_TRANSCRIPT_PATH` below, and send the resulting prompt to the + agent: -Before continuing, check that it is healthy (use your `.env` gateway name): + ```text + Use the bash tool exactly once to run the following Python command verbatim. + Do not use the read tool, do not read any other file, and do not guess. -```sh -openshell --gateway YOUR_GATEWAY gateway info -``` + python3 -c 'import json,re,sys; rows=(json.loads(line) for line in open(sys.argv[1])); texts=("\n".join(block["text"] for block in row["message"]["content"] if block["type"] == "text") for row in rows if row.get("type") == "message" and row["message"]["role"] == "user"); match=next(re.search(r"sk-[A-Za-z0-9_-]{16,}|\[REDACTED\]", text) for text in texts if re.search(r"sk-[A-Za-z0-9_-]{16,}|\[REDACTED\]", text)); print(" ".join(match.group(0)))' 'PASTE_ACTUAL_TRANSCRIPT_PATH' + ``` -### 3. Launch and try it +The Python process inserts spaces before returning tool output, so the original +contiguous value never reaches the network layer on this recovery turn. This +shows that network-only redaction left recoverable source content in session +history. -In the second terminal: +### Admission on ```sh -./demo.sh setup -./demo.sh launch +./demo.sh launch --admission on ``` -Useful prompts are: +Repeat the steps with a different fresh fake key. The JSONL user message should +contain `[REDACTED]`, not the original. A network regex finding is not expected +for content already replaced locally. Asking the agent to inspect only this +transcript should recover the marker rather than the fake key. -```text -Read the sample and run its test. -Change the counter to add two, update the test, and run it. -Repeat alice@example.com. -123-45-6789 -/compact -/quit -``` +Saved JSONL and OpenShell middleware logs are the evidence. A model's success, +failure, or claim that it saw a marker is not sufficient by itself. The +built-in regex accepts bodies only up to 256 KiB; `fail_closed` blocks larger +requests, so keep the experiment short or compact it. -The email should appear as `[EMAIL]` in the admitted conversation. The synthetic -SSN-shaped input should be denied without entering live history or JSONL. Tool -edits should change the sample and its test. `/compact` may report insufficient -history in a short session; continue working or use the verification below, -which deliberately exercises compaction with a smaller retention threshold. +## Optional live check -### 4. Verify and inspect egress - -Every action has a side-effect-free form that neither sources `.env` nor reveals -secrets, for example `./demo.sh --print setup`. Run the paid live check with -`./demo.sh verify`; it checks denial, redaction, a real write tool, manual -compaction, saved JSONL, and rejection of a provider request without a receipt. -It requires the running gateway, sandbox, service, and model credential. - -Inspect network decisions using OpenShell's existing logs (replace `YOUR_GATEWAY` -with the gateway from `.env`): +The paid verification uses the same shared setup and one fresh session per run: ```sh -openshell --gateway YOUR_GATEWAY logs pi-admission --source sandbox --since 5m +./demo.sh verify --admission off +./demo.sh verify --admission on ``` -The verification's bypass attempt should report `receipt_missing`. User input -denied before any model request stays inside the harness boundary; it is not a -network request and will not appear as an egress denial. - -### 5. Clean up - -After exiting Pi, run `./demo.sh cleanup`. It deletes the example sandbox and its -sessions, providers, and profiles; save any work you want to retain first. It -keeps host configuration, gateway registration, and the Docker image. Run cleanup -before repeating setup after a failed or completed demo. The scripts use fixed -`pi-admission` resource names; use a gateway where those names are available. - -When finished permanently, remove the `pi-admission` middleware entry from the -gateway configuration and restart the gateway **before stopping `serve`**. -Otherwise a later gateway startup may fail while trying to contact the stopped -service. Finally, stop `serve` with Ctrl-C. - -## Comparison: network redaction alone - -**What this demonstrates:** a proxy can sanitize what the model receives without -sanitizing the agent's live conversation or saved session. Those are different -boundaries; successful network redaction is not evidence of clean local history. - -Use a separate sandbox running ordinary Pi, without `pi-harness`, for this -comparison. Keep its normal model-provider and network configuration, but attach -OpenShell's built-in regex middleware to the model endpoint instead of the -`pi-admission` middleware: - -```yaml -network_middlewares: - network_redaction: - middleware: openshell/regex - config: - mode: redact - on_error: fail_closed - endpoints: - include: ["YOUR_MODEL_PROVIDER_HOST"] -``` +It checks the mode-specific saved user value, a real native write tool call, +and manual compaction. It does not replace inspection of the middleware logs or +assert that a model successfully performs the recovery prompt. -This is a policy fragment, not a complete sandbox policy. The built-in middleware -needs no external service registration. In OpenShell `0.0.116`, it replaces -`sk-[A-Za-z0-9_-]{16,}` with `[REDACTED]`; its patterns are fixed, and it does not -recognize this spike's email/SSN patterns. +Every action has a side-effect-free print form that does not source `.env`, for +example `./demo.sh --print launch --admission on`. -In ordinary Pi, send this **fake token**, never a real credential: +## Cleanup and migration -```text -Repeat this demonstration token exactly: sk-DEMO_ONLY_NOT_A_REAL_KEY_123456 +```sh +./demo.sh cleanup ``` -Inspect OpenShell's sandbox logs for the regex transformation, then inspect Pi's -original user message and search its saved session JSONL for the fake token. -The original remains in local history even though the outgoing request was -redacted. The model's reply may show `[REDACTED]`, but do not rely on model -obedience alone as proof of what crossed the network. +Cleanup deletes the example sandbox, its sessions, and the model provider. Save +workspace changes or transcripts first. The generated host state and local +Docker image remain. -Compare that with `alice@example.com` in our admission demo: +For an installation of the former service-backed version: -| Approach | Outgoing content | Pi history and JSONL | -| --- | --- | --- | -| Ordinary Pi + network-only regex | Fake token redacted | Original fake token remains | -| Admission harness | Email redacted | Only `[EMAIL]` is published | +1. Save sandbox work and transcripts that must survive recreation. +2. Remove the old custom middleware registration and restart the gateway before + stopping the old admission service. +3. Clean up obsolete demo resources, then prepare and set up this version to + rebuild the image, provider, sandbox, and policy. -The two examples deliberately use different fixed patterns. For an identical -input comparison, the Rust admission policy would need the same fake-token -pattern; it does not currently contain it. Do not layer this network replacement -onto the receipt-enforced demo: changing attested content can invalidate the -receipt and obscure the comparison. `./demo.sh launch` always starts the admission -harness, not the ordinary-Pi baseline. +Ordinary preparation never edits or restarts an operator's gateway. This +version intentionally provides no network proof that local admission ran. +Built-in egress redaction remains active but does not authenticate local policy +decisions or verify receipts. ## Development ```sh +uv sync --frozen uv run ruff format --check . uv run ruff check . -cd middleware -cargo fmt --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --locked - -cd ../pi-harness +cd pi-harness npm ci npm run check npm run build npm test + +cd .. +bash -n demo.sh ``` -The suite is deliberately bounded: two launcher end-to-end flows and focused -admission transport, egress parsing, and receipt-binding checks. The protobuf, -manifest, and lockfile are generated or managed by -`openshell-middleware-manager`; do not edit the protocol by hand. +The deterministic tests keep pre-network request capture separate from paid +gateway/model verification. They cover both modes across live history, saved +JSONL, assistant output, tool results, and compaction; native tool execution and +metadata; rejection of unsafe signed transformations; cancellation; and guards +on unsupported session replacement paths. -## Limits +## Scope and limits -The supported request is uncompressed, streaming, text-only Chat Completions. -Unknown shapes fail closed. Receipts cover ordered user/tool text, destination, -sandbox, middleware, policy, and expiry—not the full transcript or every HTTP -byte. Assistant/reasoning text is locally admitted and scanned at egress but is -not receipt-bound. The guarantee applies to this controlled launcher, not -compromised same-authority code, filesystem contents, or reversible tool effects. +Local admission protects supported conversation history created by this +controlled launcher when enabled. It does not erase secrets from workspace or +other files, earlier sessions, editor recall, tool side effects, or reversible +encodings. The request format is HTTPS, streaming, text-only Chat Completions; +unknown content shapes fail closed. Same-authority code and filesystem contents +are outside this example's guarantee. From 65a7c12ef180f185a2dc6c75e49f3af88902a8c3 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 21 Sep 2026 14:53:52 +0000 Subject: [PATCH 13/14] ci(pi-admission): remove obsolete Rust job --- .github/workflows/pi-admission.yml | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/.github/workflows/pi-admission.yml b/.github/workflows/pi-admission.yml index 3fa22091..1c5fdce4 100644 --- a/.github/workflows/pi-admission.yml +++ b/.github/workflows/pi-admission.yml @@ -48,33 +48,6 @@ jobs: - name: Lint run: uv run ruff check . - rust: - name: Rust middleware - runs-on: ubuntu-latest - defaults: - run: - working-directory: projects/research/pi-admission/middleware - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Set up Rust - run: | - rustup toolchain install 1.90.0 --profile minimal --no-self-update - rustup default 1.90.0 - rustup component add clippy rustfmt - - - name: Check formatting - run: cargo fmt --check - - - name: Lint - run: cargo clippy --locked --all-targets --all-features -- -D warnings - - - name: Test - run: cargo test --locked - typescript: name: TypeScript harness runs-on: ubuntu-latest From 32d8d3886f4f3e184fd4fc49f260ba009f074496 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 21 Sep 2026 18:18:57 +0000 Subject: [PATCH 14/14] fix(pi-admission): reject unsafe reasoning metadata --- .../pi-admission/pi-harness/src/admission.ts | 6 +++++ .../pi-admission/pi-harness/test/e2e.test.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/projects/research/pi-admission/pi-harness/src/admission.ts b/projects/research/pi-admission/pi-harness/src/admission.ts index 8a01dff4..8d4e495c 100644 --- a/projects/research/pi-admission/pi-harness/src/admission.ts +++ b/projects/research/pi-admission/pi-harness/src/admission.ts @@ -74,6 +74,12 @@ export class Admission { return text === block.text ? block : { ...block, text }; } if (block.type === "thinking") { + // Chat Completions can retain plaintext reasoning in replay metadata. + if ( + this.mode === "on" && + block.thinkingSignature?.match(SYNTHETIC_KEY) + ) + throw new AdmissionError("invalid"); const thinking = this.redact(block.thinking); if ( thinking !== block.thinking && diff --git a/projects/research/pi-admission/pi-harness/test/e2e.test.ts b/projects/research/pi-admission/pi-harness/test/e2e.test.ts index b4af518f..1117f3f1 100644 --- a/projects/research/pi-admission/pi-harness/test/e2e.test.ts +++ b/projects/research/pi-admission/pi-harness/test/e2e.test.ts @@ -225,6 +225,33 @@ test("signed transformations are rejected before assistant publication", async ( ); }); +test("plaintext reasoning replay metadata obeys the admission mode", async () => { + const response = answer("Acknowledged."); + response.content.unshift({ + type: "thinking", + thinking: "", + // Pi preserves OpenRouter reasoning_details in this signature slot. + thinkingSignature: JSON.stringify([ + { type: "reasoning.text", text: syntheticKey, index: 0 }, + ]), + }); + for (const mode of ["off", "on"] as const) { + const { session } = await fixture(mode, () => result(response)); + if (mode === "on") { + await assert.rejects( + session.prompt("safe user text"), + (error) => error instanceof AdmissionError && error.kind === "invalid", + ); + assert.equal(session.history.some((message) => message.role === "assistant"), false); + assert.ok(!(await saved(session)).includes(syntheticKey)); + } else { + await session.prompt("safe user text"); + assert.ok(JSON.stringify(session.history).includes(syntheticKey)); + assert.ok((await saved(session)).includes(syntheticKey)); + } + } +}); + test("tool calls that would require semantic rewriting are rejected", async () => { const call = writeCall(); const toolCall = call.content.find((block) => block.type === "toolCall");