diff --git a/.github/workflows/pi-admission.yml b/.github/workflows/pi-admission.yml new file mode 100644 index 00000000..c696a149 --- /dev/null +++ b/.github/workflows/pi-admission.yml @@ -0,0 +1,109 @@ +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 helpers + 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 . + + - name: Test + run: uv run pytest + + 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 fe9c9ac5..72f99f11 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. - `egress-gate`: Extensible OpenShell middleware for provider-bound HTTP requests. - `python-project-template`: Minimal, production-ready Python project scaffold diff --git a/projects/pi-admission/.env.example b/projects/pi-admission/.env.example new file mode 100644 index 00000000..68c78638 --- /dev/null +++ b/projects/pi-admission/.env.example @@ -0,0 +1,12 @@ +# 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 +# Optional Pi preference, forwarded to launch/verify. Omit for Pi's default. +# PI_CACHE_RETENTION=long +# 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..d0cfed34 --- /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 a tool-capable 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 register +./demo.sh setup +./demo.sh launch +``` + +`register` supports Homebrew and the DEB/RPM user service. It edits only the +`pi-admission` registration, records ownership, restarts the local gateway, and +refuses to overwrite or remove a registration that drifted. For remote or +custom gateway deployments, use `./demo.sh registration` and have the operator +merge the printed TOML and restart the gateway. + +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 register +./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 +/skill:review +/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 skill reads the +synthetic `project/notes.txt`, exercising real tool continuation. 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 allow, replace, deny, a real bash tool result, explicit skill, +manual and automatic compaction, and a missing-receipt request. It requires a +running service, gateway, sandbox, and paid provider access; fixture 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, and its owned gateway registration. It restarts the gateway and +retains generated host configuration and the Docker image. Stop `serve` +separately with Ctrl-C. + +## Development checks + +```sh +uv run ruff format --check . +uv run ruff check . +uv run pytest + +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 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, tool continuations, +reasoning, project instructions, explicit skills, 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. It blocks shell shortcuts, resume/import, branching, +renaming, live model switching, resource reload, and arbitrary extensions. +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 undo 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..c1cb9ab8 --- /dev/null +++ b/projects/pi-admission/demo.sh @@ -0,0 +1,147 @@ +#!/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) +project=$example +state=$project/.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") +pi_env=(/usr/bin/env) +if [[ -n ${PI_CACHE_RETENTION:-} ]]; then + pi_env+=("PI_CACHE_RETENTION=$PI_CACHE_RETENTION") +fi +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 +} +registration() { + run uv run --frozen python "$example/gateway-registration.py" "$1" --state "$state" --gateway "$gateway" + if $print_only; then + printf '# Helper edits only pi-admission, waits for health, and internally runs: ' + case "$OSTYPE" in + darwin*) run brew services restart openshell ;; + linux*) run systemctl --user restart openshell-gateway ;; + *) printf 'no supported service manager\n' ;; + esac + fi +} +cd "$project" +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" + ;; + register|unregister) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi + registration "$action" + ;; + 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 -- "${pi_env[@]}" /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 -- "${pi_env[@]}" /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" + registration unregister + 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 ' register Add middleware to the local gateway and restart it\n' + printf ' unregister Remove that registration and restart the gateway\n' + printf ' registration Show the TOML entry (manual deployments only; does not register)\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 allow/deny/redact, tools, skill, compaction and bypass checks\n' + printf ' cleanup Delete sandbox/providers/sessions; unregister middleware\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/gateway-registration.py b/projects/pi-admission/gateway-registration.py new file mode 100644 index 00000000..48850a5a --- /dev/null +++ b/projects/pi-admission/gateway-registration.py @@ -0,0 +1,317 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Register this demo with a local, installer-managed OpenShell gateway.""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import re +import shlex +import stat +import subprocess +import sys +import tempfile +import time +import tomllib +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlsplit + + +@dataclass(frozen=True) +class GatewayRegistration: + name: str + + +def configure(action: str, state: Path, gateway: str) -> None: + record = state / "gateway-registration.json" + if action == "unregister" and not record.exists(): + print("No demo-managed gateway registration to remove.") + return + gateways = json.loads(_output("openshell", "gateway", "list", "--output", "json")) + selected = next((item for item in gateways if item["name"] == gateway), None) + endpoint = urlsplit(selected["endpoint"] if selected else "") + if ( + endpoint.scheme != "https" + or endpoint.hostname not in {"localhost", "127.0.0.1", "::1"} + or endpoint.port != 17670 + ): + raise ValueError( + "Select the local installer-managed gateway on HTTPS port 17670." + ) + + config, restart = _local_service() + registration = {"gateway": gateway, "config": str(config)} + saved = json.loads(record.read_text()) if record.exists() else None + if saved is not None and any(saved.get(k) != v for k, v in registration.items()): + raise ValueError( + "Gateway/config changed; restore the previous selection to clean up first." + ) + + if action == "register": + fragment = (state / "middleware.toml").read_text() + desired = tomllib.loads(fragment)["openshell"]["supervisor"]["middleware"][0] + original = ( + config.read_text() if config.exists() else "[openshell]\nversion = 1\n" + ) + # The shared reader validates the middleware table before we edit anything. + matches = [ + r for r in list_gateway_registrations(config) if r.name == "pi-admission" + ] + if matches: + entries = tomllib.loads(original)["openshell"]["supervisor"]["middleware"] + existing = [ + entry for entry in entries if entry.get("name") == "pi-admission" + ] + if existing != [desired] or saved is None or saved.get("entry") != desired: + raise ValueError( + "pi-admission is already registered; refusing to overwrite it." + ) + else: + updated = original.rstrip() + "\n\n" + fragment + tomllib.loads(updated) + # Remember ownership so cleanup also works after a failed restart. + record.write_text(json.dumps({**registration, "entry": desired}) + "\n") + config.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", dir=config.parent, delete=False + ) as temporary: + try: + temporary.write(updated) + temporary.close() + if config.exists(): + os.chmod(temporary.name, config.stat().st_mode & 0o777) + Path(temporary.name).replace(config) + finally: + Path(temporary.name).unlink(missing_ok=True) + print(f"Registered pi-admission in {config}", flush=True) + else: + entries = ( + ( + tomllib.loads(config.read_text()) + .get("openshell", {}) + .get("supervisor", {}) + .get("middleware", []) + ) + if config.exists() + else [] + ) + existing = [entry for entry in entries if entry.get("name") == "pi-admission"] + if existing and (saved is None or existing != [saved.get("entry")]): + raise ValueError( + "pi-admission registration changed; refusing to remove it." + ) + remove_gateway_config(config, middleware_name="pi-admission") + print(f"Removed pi-admission from {config}", flush=True) + + # Restart even on a retry: the previous write may have succeeded but reload failed. + print( + f"+ {shlex.join(restart)} (briefly interrupts this gateway)", + flush=True, + ) + subprocess.run(restart, check=True) + _wait_for_gateway(gateway) + if action == "unregister": + record.unlink() + + +def _output(*command: str) -> str: + return subprocess.check_output(command, text=True, timeout=10) + + +def default_gateway_config_path() -> Path: + configured = os.environ.get("OPENSHELL_GATEWAY_CONFIG") + if configured: + return Path(configured) + root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return root / "openshell/gateway.toml" + + +def list_gateway_registrations(path: Path) -> tuple[GatewayRegistration, ...]: + if not path.exists() or not path.read_text().strip(): + return () + values = _load_gateway_config(path.read_text(), path) + return tuple(GatewayRegistration(str(entry["name"])) for entry in _entries(values)) + + +def remove_gateway_config(path: Path, *, middleware_name: str) -> None: + if not path.exists() or not path.read_text().strip(): + return + original = path.read_text() + values = _load_gateway_config(original, path) + entries = _entries(values) + indexes = [ + index + for index, entry in enumerate(entries) + if entry.get("name") == middleware_name + ] + if len(indexes) > 1: + raise ValueError(f"{path} contains duplicate {middleware_name!r} registrations") + if not indexes: + return + blocks = list(_MIDDLEWARE_BLOCK_PATTERN.finditer(original)) + if len(blocks) != len(entries): + raise ValueError(f"Could not safely locate middleware registrations in {path}") + block = blocks[indexes[0]] + updated = ( + original[: block.start()] + + _trailing_layout(block.group(0)) + + original[block.end() :] + ) + updated_values = _load_gateway_config(updated, path) + expected = [entry for index, entry in enumerate(entries) if index != indexes[0]] + if _entries(updated_values) != expected or _without_entries( + updated_values + ) != _without_entries(values): + raise ValueError( + f"Could not safely remove the middleware registration from {path}" + ) + _write_atomically(path, updated) + + +def _load_gateway_config(contents: str, path: Path) -> dict[str, object]: + try: + values = tomllib.loads(contents) + except tomllib.TOMLDecodeError as error: + raise ValueError(f"{path} is not valid TOML") from error + openshell = values.get("openshell") + if not isinstance(openshell, dict) or openshell.get("version") != 1: + raise ValueError(f"{path} must contain [openshell] with version = 1") + return values + + +def _entries(values: dict[str, object]) -> list[dict[str, object]]: + openshell = values["openshell"] + if not isinstance(openshell, dict): + raise ValueError("invalid openshell table") + supervisor = openshell.get("supervisor", {}) + if not isinstance(supervisor, dict): + raise ValueError("invalid openshell.supervisor table") + entries = supervisor.get("middleware", []) + if not isinstance(entries, list) or not all( + isinstance(entry, dict) for entry in entries + ): + raise ValueError("invalid openshell.supervisor.middleware tables") + return entries + + +def _without_entries(values: dict[str, object]) -> dict[str, object]: + copied = copy.deepcopy(values) + openshell = copied["openshell"] + assert isinstance(openshell, dict) + supervisor = openshell.get("supervisor") + if isinstance(supervisor, dict): + supervisor.pop("middleware", None) + if not supervisor: + openshell.pop("supervisor", None) + return copied + + +def _trailing_layout(block: str) -> str: + lines = block.splitlines(keepends=True) + for index in range(len(lines) - 1, -1, -1): + stripped = lines[index].lstrip() + if stripped.strip() and not stripped.startswith("#"): + return "".join(lines[index + 1 :]) + raise ValueError("middleware block header is unavailable") + + +def _write_atomically(path: Path, contents: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600 + with tempfile.NamedTemporaryFile( + mode="w", dir=path.parent, delete=False + ) as temporary: + try: + temporary.write(contents) + temporary.close() + Path(temporary.name).chmod(mode) + Path(temporary.name).replace(path) + finally: + Path(temporary.name).unlink(missing_ok=True) + + +def _local_service() -> tuple[Path, list[str]]: + config = default_gateway_config_path() + service_env = config.with_name("gateway.env") + if sys.platform == "darwin": + # Match the Homebrew wrapper: user config, then prefix config. + prefix = Path(_output("brew", "--prefix").strip()) / "var/openshell" + if not service_env.is_file(): + service_env = prefix / "gateway.env" + if not config.is_file(): + config = prefix / "gateway.toml" + config = config.resolve(strict=True) + restart = ["brew", "services", "restart", "openshell"] + elif sys.platform == "linux": + # DEB/RPM installations use defaults until a user config is created. + if ( + _output( + "systemctl", + "--user", + "show", + "openshell-gateway", + "--property=LoadState", + "--value", + ).strip() + != "loaded" + ): + raise ValueError("No installer-managed OpenShell user service found.") + restart = ["systemctl", "--user", "restart", "openshell-gateway"] + else: + raise ValueError("No supported local gateway service manager found.") + # Custom service environments are operator-managed, not inferred from our shell. + if os.environ.get("OPENSHELL_GATEWAY_CONFIG") or ( + service_env.is_file() + and any( + "OPENSHELL_GATEWAY_CONFIG" in line + for line in service_env.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + ): + raise ValueError( + "Custom gateway config override: use operator-managed registration." + ) + return config.resolve(), restart + + +def _wait_for_gateway(gateway: str) -> None: + command = ["openshell", "--gateway", gateway, "gateway", "info", "--output", "json"] + print(f"Waiting for healthy gateway: {shlex.join(command)}", flush=True) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=5) + if ( + result.returncode == 0 + and json.loads(result.stdout)["status"] == "healthy" + ): + return + except subprocess.TimeoutExpired: + pass + time.sleep(1) + raise ValueError( + "Gateway did not become healthy. Check the gateway service logs, then retry." + ) + + +_MIDDLEWARE_BLOCK_PATTERN = re.compile( + r"(?ms)^[ \t]*\[\[openshell\.supervisor\.middleware\]\][^\n]*\n" + r".*?(?=^[ \t]*\[\[?[A-Za-z0-9_-]|\Z)" +) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("register", "unregister")) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--gateway", required=True) + args = parser.parse_args() + try: + configure(args.action, args.state, args.gateway) + except (OSError, ValueError, subprocess.SubprocessError) as error: + parser.exit(1, f"{error}\n") 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..0439e7c0 --- /dev/null +++ b/projects/pi-admission/middleware/src/lib.rs @@ -0,0 +1,221 @@ +//! 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", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_constants_are_bounded() { + assert_eq!(SERVICE_NAME, "pi-admission"); + assert_eq!(MAX_BODY_BYTES, 4 * 1024 * 1024); + assert!(RECEIPT_HEADER.starts_with("x-pi-admission-")); + } +} 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..cb6d499b --- /dev/null +++ b/projects/pi-admission/middleware/src/policy.rs @@ -0,0 +1,398 @@ +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::*; + use serde_json::json; + + #[test] + fn deny_wins_and_json_escaping_cannot_bypass_policy() { + let body = json!({ + "schema_version": "openshell.pi-message.v1", + "origin": "user", + "text": "mail me at alice@example.com, id 123-45-6789" + }); + assert!(matches!( + evaluate_candidate("user_message", body), + CandidateDecision::Deny("ssn_detected") + )); + let escaped: Value = serde_json::from_str( + r#"{"schema_version":"openshell.pi-message.v1","origin":"user","text":"alice\u0040example.com"}"#, + ) + .unwrap(); + match evaluate_candidate("user_message", escaped) { + CandidateDecision::Replace(value) => assert_eq!(value["text"], "[EMAIL]"), + _ => panic!("decoded email should be replaced"), + } + } + + #[test] + fn executable_and_signed_content_is_not_rewritten() { + let body = json!({ + "schema_version": "openshell.pi-assistant-message.v1", + "text": "ok", + "tool_calls": [{"id":"1","name":"bash","arguments":{"command":"echo alice@example.com"},"thought_signature":null}], + "thinking": [] + }); + assert!(matches!( + evaluate_candidate("assistant_message", body), + CandidateDecision::Deny("immutable_content_detected") + )); + } + + #[test] + fn egress_inspects_decoded_content_without_rewriting() { + 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..06fdc9ee --- /dev/null +++ b/projects/pi-admission/middleware/src/receipt.rs @@ -0,0 +1,328 @@ +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") + ); + } + + #[test] + fn expired_receipt_fails_closed() { + let authority = ReceiptAuthority::new(SigningKey::from_bytes(&[9; 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_at(&projection, context, "session", "submission", 100) + .unwrap(); + let context = ReceiptContext { + middleware_name: "pi-admission", + sandbox_id: "sandbox-1", + target: &target, + }; + assert_eq!( + authority.verify_header_at(&receipt, &projection, context, 400), + Err("receipt_expired") + ); + } +} 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..34058156 --- /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 dist/test/session.test.js && node dist/test/admission.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..4006ccd3 --- /dev/null +++ b/projects/pi-admission/pi-harness/src/cli.ts @@ -0,0 +1,63 @@ +// 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, convertToLlm } 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: { + cwd: { type: "string", default: "/sandbox/project" }, + "session-dir": { type: "string", default: "/sandbox/sessions" }, + admission: { type: "string" }, + prompt: { type: "string" }, + }, + }); + const apiKey = process.env.PI_MODEL_API_KEY; + const admissionKey = process.env.PI_ADMISSION_TOKEN; + // Retain endpoint-bound placeholders only in the application, not in tool + // child environments. This is hygiene, not isolation from same-authority code. + 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: values.cwd, + sessionDir: values["session-dir"], + agentDir: "/app/agent", + model, + apiKey, + admission: new Admission( + createHttpEvaluator(values.admission, admissionKey, randomUUID()), + ), + }); + if (values.prompt !== undefined) { + try { + await runtime.session.bindExtensions({}); + await runtime.session.prompt(values.prompt); + console.log( + JSON.stringify(convertToLlm(runtime.session.messages), null, 2), + ); + } finally { + await runtime.dispose(); + } + return; + } + 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..10a9dc0f --- /dev/null +++ b/projects/pi-admission/pi-harness/src/verify.ts @@ -0,0 +1,125 @@ +// 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 type { Message } from "@earendil-works/pi-ai"; +import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; +import { AdmissionSession } from "./session.js"; +import { configureProxy } from "./network.js"; +import { loadSelectedModel } from "./model.js"; + +export function assertProjectRead(history: readonly Message[]): void { + assert.ok( + history.some((message) => + message.role === "toolResult" && + message.toolName === "read" && + !message.isError && + message.content.some((block) => + block.type === "text" && + block.text.includes("This is a real file in the sandbox project.") && + block.text.includes("[EMAIL]"), + ), + ), + "The read tool must successfully return the approved notes.txt content", + ); +} + +/** Real service, upstream runtime, real project tools, and the configured model. */ +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 makeSession = (compactAtTokens?: number) => + AdmissionSession.create({ + cwd: "/sandbox/project", + sessionDir: "/sandbox/sessions", + agentDir: "/app/agent", + model, + apiKey, + compactAtTokens, + 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 an approval receipt must be blocked", + ); + console.log("PASS raw provider request without receipt is blocked"); + const session = await makeSession(); + await assert.rejects( + session.prompt("123-45-6789"), + (error) => error instanceof AdmissionError && error.kind === "denied", + ); + assert.deepEqual(session.entries, []); + console.log( + "PASS denied user input is absent from live history and Pi entries", + ); + await session.prompt("Reply briefly to this harmless text: alice@example.com"); + await session.prompt( + "/skill:review Use the read tool to read notes.txt; do not guess its contents.", + ); + assertProjectRead(session.history); + // Force a short-demo compaction without changing the interactive launcher defaults. + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + assert.ok( + await session.compact(), + "Manual compaction must summarize an older turn", + ); + const saved = await readFile(session.sessionFile, "utf8"); + for (const snapshot of [ + JSON.stringify(session.history), + JSON.stringify(session.entries), + saved, + ]) { + assert.ok( + !snapshot.includes("123-45-6789") && !snapshot.includes("alice@example.com"), + ); + assert.ok(snapshot.includes("[EMAIL]")); + } + console.log( + "PASS real model, redacted input, rendered skill, tool continuation, manual compaction, and JSONL history", + ); + const automatic = await makeSession(1); + automatic.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + await automatic.prompt("Reply with a brief greeting."); + await automatic.prompt("Reply with a brief farewell."); + assert.ok(automatic.entries.some((entry) => entry.type === "compaction")); + console.log("PASS automatic compaction through the same admission boundary"); + console.log( + `Saved evidence: ${session.sessionFile}\n${automatic.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; no checks were skipped.", + ); + process.exitCode = 1; + }); +} diff --git a/projects/pi-admission/pi-harness/test/admission.test.ts b/projects/pi-admission/pi-harness/test/admission.test.ts new file mode 100644 index 00000000..c3043013 --- /dev/null +++ b/projects/pi-admission/pi-harness/test/admission.test.ts @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { + Admission, + AdmissionError, + createHttpEvaluator, + textOnly, +} from "../src/admission.js"; + +test("allow preserves native messages; executable and signed reasoning changes fail closed", async () => { + assert.throws(() => textOnly([{ type: "image" }]), AdmissionError); + const message: AssistantMessage = { + role: "assistant", + api: "openai-completions", + model: "test", + provider: "test", + timestamp: 0, + stopReason: "toolUse", + content: [ + { type: "text", text: "before" }, + { type: "thinking", thinking: "private reasoning", thinkingSignature: "provider-signature" }, + { + type: "toolCall", + id: "call", + name: "bash", + arguments: { command: "original" }, + thoughtSignature: "tool-signature", + }, + { type: "text", text: "after", textSignature: "text-signature" }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + const allowed = new Admission(async () => ({ decision: "allow", replacement: null, receipt: null })); + assert.strictEqual(await allowed.message(message), message); + for (const role of ["user", "toolResult"] as const) { + const candidate = { role, content: [{ type: "text" as const, text: "one" }, { type: "text" as const, text: "two" }], + timestamp: 1, toolCallId: "call", toolName: "read", isError: false }; + assert.strictEqual(await allowed.message(candidate), candidate); + } + const signedChange = new Admission(async (_kind, body) => ({ + decision: "replace", receipt: null, + replacement: { ...body, thinking: [{ text: "changed", signature: "provider-signature" }] }, + })); + await assert.rejects(signedChange.message(message), AdmissionError); + const unsigned = { ...message, content: [{ type: "thinking" as const, thinking: "private reasoning" }] }; + const redactor = new Admission(async (_kind, body) => ({ + decision: "replace", receipt: null, + replacement: { ...body, thinking: [{ text: "approved reasoning", signature: null }] }, + })); + assert.deepEqual((await redactor.message(unsigned)).content, [{ type: "thinking", thinking: "approved reasoning" }]); + const admission = new Admission(async (_kind, body) => ({ + decision: "replace", + replacement: { ...body, tool_calls: [] }, + receipt: null, + })); + await assert.rejects(admission.message(message), AdmissionError); +}); + +test("send-only redaction is rejected, and receipt projection preserves user/tool order", async () => { + const admission = new Admission(async (_kind, body) => { + assert.deepEqual(body.entries, [ + { role: "user", text: "hello" }, + { role: "tool", tool_call_id: "call", text: "(no tool output)" }, + ]); + return { decision: "replace", replacement: body, receipt: "receipt" }; + }); + await assert.rejects( + admission.receipt({ + messages: [ + { role: "user", content: "hello", timestamp: 0 }, + { + role: "toolResult", + toolCallId: "call|provider-suffix", + toolName: "read", + content: [], + isError: false, + timestamp: 0, + }, + ], + }), + AdmissionError, + ); +}); + +test("HTTP client rejects insecure configuration and malformed service output", async () => { + assert.throws(() => + createHttpEvaluator("http://service.test", "placeholder", "session"), + ); + const original = globalThis.fetch; + try { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + decision: "allow", + replacement: { text: "unchecked" }, + receipt: null, + }), + ); + await assert.rejects( + createHttpEvaluator( + "https://service.test", + "placeholder", + "session", + )("user_message", {}), + AdmissionError, + ); + globalThis.fetch = async () => { + throw new Error("RAW_SECRET"); + }; + await assert.rejects( + createHttpEvaluator( + "https://service.test", + "placeholder", + "session", + )("user_message", {}), + (error) => + error instanceof AdmissionError && + !error.message.includes("RAW_SECRET"), + ); + } finally { + globalThis.fetch = original; + } +}); diff --git a/projects/pi-admission/pi-harness/test/service-integration.ts b/projects/pi-admission/pi-harness/test/service-integration.ts new file mode 100644 index 00000000..fd02baa1 --- /dev/null +++ b/projects/pi-admission/pi-harness/test/service-integration.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Invoked by pytest with real local admission/provider endpoints. No stream or +// evaluator is replaced: Pi serializes requests and consumes the provider SSE. +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { InteractiveMode } from "@earendil-works/pi-coding-agent"; +import { + Admission, + AdmissionError, + createHttpEvaluator, +} from "../src/admission.js"; +import { AdmissionSession, createAdmissionRuntime } from "../src/session.js"; +import { loadSelectedModel } from "../src/model.js"; +import { assertProjectRead } from "../src/verify.js"; + +const [endpoint, directory] = process.argv.slice(2); +const model = await loadSelectedModel(join(directory, "image")); +assert.equal(model.id, "z-ai/glm-5.3-flash"); +assert.equal(model.compat?.supportsDeveloperRole, false); +// Only the endpoint changes: exercise the prepared catalog through Pi's parser. +model.baseUrl = `${endpoint}/api/v1`; +const options = (compactAtTokens?: number) => ({ + cwd: join(directory, "image/project"), + sessionDir: join(directory, "sessions"), + agentDir: join(directory, "agent"), + model, + apiKey: "local-test-credential", + admission: new Admission( + createHttpEvaluator( + `${endpoint}/v1/admission`, + "test-admission-credential", + randomUUID(), + ), + ), + compactAtTokens, +}); + +if (process.argv.includes("--tui")) { + const runtime = await createAdmissionRuntime(options()); + assert.equal(runtime.services.modelRuntime.getError(), undefined); + const interactive = new InteractiveMode(runtime, { + initialMessage: "Please repeat alice@example.com and café.", + initialMessages: ["/skill:review"], + }); + await interactive.init(); + runtime.session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + await interactive.run(); + process.exit(0); +} + +const create = async (compactAtTokens?: number) => { + const session = await AdmissionSession.create(options(compactAtTokens)); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + return session; +}; + +const session = await create(); +if (process.argv.includes("--settings")) { + // The Python provider checks the actual serialized model/cache settings. + await session.prompt("Check model settings"); + process.exit(0); +} +assert.equal(session.modelRuntime.getError(), undefined); +await assert.rejects(session.prompt("123-45-6789"), AdmissionError); +assert.equal(session.history.length, 0); +assert.equal(session.entries.length, 0); +await session.prompt("Please repeat alice@example.com and café."); +await session.prompt("/skill:review"); +assertProjectRead(session.history); +for (const snapshot of [ + JSON.stringify(session.history), + await readFile(session.sessionFile, "utf8"), +]) { + assert.ok(snapshot.includes("[EMAIL]")); + assert.ok( + !snapshot.includes("alice@example.com") && !snapshot.includes("123-45-6789"), + ); +} +assert.ok(await session.compact()); +assert.ok(session.entries.some((entry) => entry.type === "compaction")); +assert.match(JSON.stringify(session.history), /Approved summary/); + +const automatic = await create(1); +await automatic.prompt("Hello"); +await automatic.prompt("One more turn"); +assert.ok(automatic.entries.some((entry) => entry.type === "compaction")); +for (const current of [session, automatic]) { + for (const snapshot of [ + JSON.stringify(current.history), + JSON.stringify(current.entries), + await readFile(current.sessionFile, "utf8"), + ]) { + assert.ok( + !snapshot.includes("alice@example.com") && !snapshot.includes("123-45-6789"), + ); + } +} diff --git a/projects/pi-admission/pi-harness/test/session.test.ts b/projects/pi-admission/pi-harness/test/session.test.ts new file mode 100644 index 00000000..628828ad --- /dev/null +++ b/projects/pi-admission/pi-harness/test/session.test.ts @@ -0,0 +1,669 @@ +// 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, mkdir, readFile, writeFile, readdir } 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 AdmissionKind, + type AdmissionResponse, + type Evaluate, +} from "../src/admission.js"; +import { + AdmissionSession, + createAdmissionRuntime, + projectTools, +} 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, +}; +const deny: AdmissionResponse = { + decision: "deny", + replacement: null, + receipt: null, +}; + +function answer( + text: string, + calls: { + id: string; + name: string; + arguments: Record; + }[] = [], +): AssistantMessage { + return { + role: "assistant", + content: [ + { type: "text", text }, + ...calls.map((call) => ({ type: "toolCall" as const, ...call })), + ], + api: model.api, + model: model.id, + provider: model.provider, + timestamp: Date.now(), + stopReason: calls.length ? "toolUse" : "stop", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +async function fixture( + evaluate?: Evaluate, + responses: AssistantMessage[] = [answer("Done")], + compactAtTokens?: number, +) { + const cwd = await mkdtemp(join(tmpdir(), "pi-admission-test-")); + await writeFile(join(cwd, "AGENTS.md"), "Use the project tools."); + await mkdir(join(cwd, ".pi/skills/example"), { recursive: true }); + await writeFile( + join(cwd, ".pi/skills/example/SKILL.md"), + "---\nname: example\ndescription: Example skill\n---\nSKILL_CANDIDATE", + ); + const requests: Context[] = []; + const kinds: AdmissionKind[] = []; + const stream: StreamFn = (_model, context, options) => { + assert.ok(options?.headers?.["x-pi-admission-receipt"]); + requests.push(structuredClone({ ...context, tools: undefined })); + const response = responses.shift(); + assert.ok(response, "unexpected additional model call"); + const result = createAssistantMessageEventStream(); + result.push({ + type: "done", + reason: response.stopReason === "toolUse" ? "toolUse" : "stop", + message: response, + }); + return result; + }; + const admission = new Admission(async (kind, body, signal) => { + kinds.push(kind); + return ( + (await evaluate?.(kind, body, signal)) ?? + (kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow) + ); + }); + const session = await AdmissionSession.create({ + cwd, + sessionDir: join(cwd, "sessions"), + agentDir: join(cwd, "agent"), + model, + apiKey: "placeholder", + admission, + stream, + compactAtTokens, + }); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + return { session, cwd, requests, kinds }; +} + +async function disk(session: AdmissionSession): Promise { + try { + return await readFile(session.sessionFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + +for (const [kind, marker, prompt, responses] of [ + ["user_message", "CANDIDATE", "CANDIDATE", [answer("done")]], + ["user_message", "SKILL_CANDIDATE", "/skill:example", [answer("done")]], + ["assistant_message", "CANDIDATE", "hello", [answer("CANDIDATE")]], + ["assistant_message", "REASONING_CANDIDATE", "hello", [{ + ...answer("done"), + content: [{ type: "thinking", thinking: "REASONING_CANDIDATE" }, { type: "text", text: "done" }] as AssistantMessage["content"], + }]], + [ + "tool_result", + "CANDIDATE", + "read", + [ + answer("", [ + { id: "call", name: "read", arguments: { path: "candidate.txt" } }, + ]), + ], + ], + [ + "tool_result", + "Requested tool is not available", + "read", + [answer("", [{ id: "call", name: "missing", arguments: {} }])], + ], +] as const) { + test(`pending and denied ${kind}: ${marker}`, async () => { + let release!: (result: AdmissionResponse) => void; + let reached!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const seen = new Promise((resolve) => { + reached = resolve; + }); + const { session, cwd } = await fixture( + async (current, body) => { + if (current === kind && JSON.stringify(body).includes(marker)) { + reached(); + return pending; + } + return current === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [...responses], + ); + const events: unknown[] = []; + session.subscribe((event) => { + events.push(structuredClone(event)); + }); + await writeFile(join(cwd, "candidate.txt"), "CANDIDATE"); + const run = session.prompt(prompt); + await seen; + assert.ok(!JSON.stringify(session.entries).includes(marker)); + assert.ok(!JSON.stringify(session.history).includes(marker)); + assert.ok(!JSON.stringify(session.messages).includes(marker)); + assert.ok(!JSON.stringify(events).includes(marker)); + assert.ok(!(await disk(session)).includes(marker)); + release(deny); + await assert.rejects(run); + assert.ok(!JSON.stringify(events).includes(marker)); + assert.ok(!JSON.stringify(session.history).includes(marker)); + assert.ok(!(await disk(session)).includes(marker)); + }); +} + +for (const [shape, edits] of [ + ["JSON string", { edits: JSON.stringify([{ oldText: "before", newText: "after" }]) }], + ["single edit", { edits: { oldText: "before", newText: "after" } }], + ["legacy", { oldText: "before", newText: "after" }], +] as const) { + test(`native edit preparation preserves approved ${shape} arguments`, async () => { + const args = { path: "edit.txt", ...edits }; + const { session, cwd } = await fixture(undefined, [ + answer("", [{ id: "edit", name: "edit", arguments: args }]), + answer("Done"), + ]); + await writeFile(join(cwd, "edit.txt"), "before"); + await session.prompt("Edit the file"); + assert.equal(await readFile(join(cwd, "edit.txt"), "utf8"), "after"); + const assistant = session.history.find( + (message) => message.role === "assistant", + )!; + const call = assistant.content.find((block) => block.type === "toolCall")!; + assert.deepEqual(call.arguments, args); + const saved = (await disk(session)) + .trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual( + saved.find((entry) => entry.message?.role === "assistant").message.content + .find((block: { type: string }) => block.type === "toolCall").arguments, + args, + ); + }); +} + +for (const decision of ["deny", "replace"] as const) { + test(`split-turn compaction waits for admission: ${decision}`, async () => { + let release!: (result: AdmissionResponse) => void; + let reached!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + const seen = new Promise((resolve) => { reached = resolve; }); + let candidate: Record = {}; + const { session, cwd } = await fixture(async (kind, body) => { + if (kind === "compaction_summary") { + candidate = body; + reached(); + return pending; + } + return kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow; + }, [ + answer("", [{ id: "read", name: "read", arguments: { path: "notes.txt" } }]), + answer("Done"), + answer("RAW_SUMMARY"), + ]); + await writeFile(join(cwd, "notes.txt"), "Approved note"); + await session.prompt("Read notes.txt"); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + const before = structuredClone(session.history); + const saved = await disk(session); + const compact = session.compact(); + const settled = decision === "deny" ? assert.rejects(compact) : compact; + await seen; + // Native compaction appends file-operation text; that must be admitted too. + assert.ok(String(candidate.text).includes("RAW_SUMMARY")); + assert.ok(String(candidate.text).includes("notes.txt")); + assert.deepEqual(session.history, before); + assert.equal(await disk(session), saved); + release({ + decision, + replacement: decision === "replace" ? { ...candidate, text: "APPROVED_SUMMARY" } : null, + receipt: null, + }); + await settled; + if (decision === "deny") { + assert.deepEqual(session.history, before); + assert.equal(await disk(session), saved); + } else { + const entry = session.entries.find((entry) => entry.type === "compaction")!; + assert.equal(entry.summary, "APPROVED_SUMMARY"); + assert.equal(entry.details, undefined); + assert.ok(JSON.stringify(session.history).includes("APPROVED_SUMMARY")); + assert.ok((await disk(session)).includes("Read notes.txt"), "history is append-only"); + } + for (const snapshot of [JSON.stringify(session.history), await disk(session)]) + assert.ok(!snapshot.includes("RAW_SUMMARY")); + }); +} + +test("real bash is bounded before Pi can spill an unchecked output log", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-bash-test-")); + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith("pi-bash-"), + ); + const bash = projectTools(cwd).find((tool) => tool.name === "bash")!; + try { + await bash.execute("call", { command: "head -c 100000 /dev/zero" }); + } catch { + /* Pi reports the bounded operation as a tool error. */ + } + const after = (await readdir(tmpdir())).filter((name) => + name.startsWith("pi-bash-"), + ); + assert.deepEqual(after, before); +}); + +test("provider errors and partial responses never become history", async () => { + const error = { + ...answer("PARTIAL_RESPONSE"), + stopReason: "error" as const, + errorMessage: "RAW_PROVIDER_ERROR", + }; + const { session } = await fixture(undefined, [error]); + await assert.rejects(session.prompt("hello")); + assert.equal(session.history.length, 1); + assert.ok(!JSON.stringify(session.entries).includes("PARTIAL_RESPONSE")); + assert.ok(!(await disk(session)).includes("RAW_PROVIDER_ERROR")); +}); + +test("denied tool result stops model continuation and unexecuted calls", async () => { + const { session, cwd, requests } = await fixture( + async (kind, body) => { + if ( + kind === "tool_result" && + JSON.stringify(body).includes("FORBIDDEN_OUTPUT") + ) + return deny; + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [ + answer("", [ + { id: "read", name: "read", arguments: { path: "candidate.txt" } }, + { + id: "write", + name: "write", + arguments: { + path: "must-not-exist", + content: "unchecked continuation", + }, + }, + ]), + ], + ); + await writeFile(join(cwd, "candidate.txt"), "FORBIDDEN_OUTPUT"); + await assert.rejects(session.prompt("Use the tools")); + assert.equal(requests.length, 1); + assert.equal( + session.history.filter((message) => message.role === "toolResult").length, + 2, + ); + await assert.rejects(readFile(join(cwd, "must-not-exist"))); + assert.ok(!JSON.stringify(session.entries).includes("FORBIDDEN_OUTPUT")); +}); + +test("unavailable tool admission leaves an unfinished session stopped", async () => { + const { session, requests } = await fixture( + async (kind) => { + if (kind === "tool_result") throw new Error("SERVICE_UNAVAILABLE"); + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("", [{ id: "call", name: "missing", arguments: {} }])], + ); + await assert.rejects(session.prompt("hello")); + assert.equal(session.isStopped, true); + assert.equal(requests.length, 1); + await assert.rejects(session.prompt("continue")); + assert.equal( + session.history.filter((message) => message.role === "toolResult").length, + 0, + ); +}); + +test("project context is checked before any model request or message write", async () => { + await assert.rejects( + fixture(async (kind, body) => { + assert.equal(kind, "system_context"); + assert.ok(String(body.text).includes("Use the project tools.")); + assert.ok(String(body.text).includes("Example skill")); + return deny; + }), + ); +}); + +test("cancelled admission cannot append even when the service subsequently allows", async () => { + const { session, requests } = await fixture(async (kind) => { + if (kind === "user_message") session.agent.abort(); + return allow; + }); + await assert.rejects(session.prompt("CANCELLED")); + assert.deepEqual(session.entries, []); + assert.equal(requests.length, 0); +}); + +for (const enabled of [true, false]) { + test(`overflow respects auto-compaction ${enabled}`, async () => { + const overflow = { + ...answer(""), + stopReason: "error" as const, + errorMessage: "exceeds the context window", + }; + const { session, requests, kinds } = await fixture(undefined, [ + answer("first"), + overflow, + answer("summary"), + answer("retry result"), + ]); + await session.prompt("first turn"); + session.setAutoCompactionEnabled(enabled); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + if (enabled) await session.prompt("next turn"); + else await assert.rejects(session.prompt("next turn"), /Context is too large/); + assert.equal(requests.length, enabled ? 4 : 2); + assert.equal( + kinds.filter((kind) => kind === "compaction_summary").length, + enabled ? 1 : 0, + ); + assert.equal(JSON.stringify(session.history).includes("retry result"), enabled); + assert.ok(!(await disk(session)).includes("exceeds the context window")); + }); +} + +test("system rebuilds remain private while user admission is pending or denied", async () => { + let release!: () => void; + let reached!: () => void; + const hold = new Promise((resolve) => { + release = resolve; + }); + const seen = new Promise((resolve) => { + reached = resolve; + }); + let denyUser = true; + const { session, requests } = await fixture(async (kind, body) => { + if (kind === "system_context") return { + ...allow, + decision: "replace", + replacement: { ...body, text: "APPROVED_SYSTEM" }, + }; + if (kind === "user_message" && denyUser) { + reached(); + await hold; + return deny; + } + return kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow; + }); + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); + const running = session.prompt("denied"); + const rejected = assert.rejects(running); + await seen; + assert.equal(session.agent.state.systemPrompt, "APPROVED_SYSTEM"); + release(); + await rejected; + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); + assert.equal(session.messages.length, 0); + denyUser = false; + await session.prompt("allowed"); + assert.equal(requests[0].systemPrompt, "APPROVED_SYSTEM"); + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); +}); + +test("native session persists each approved message once and never renders tool details", async () => { + const { session, cwd } = await fixture(undefined, [ + answer("", [ + { id: "read", name: "read", arguments: { path: "notes.txt" } }, + ]), + answer("Done"), + ]); + await writeFile(join(cwd, "notes.txt"), "Approved file"); + const tool = session.agent.state.tools.find((tool) => tool.name === "read")!; + const execute = tool.execute; + tool.execute = async (id, args, signal, onUpdate) => { + onUpdate?.({ + content: [{ type: "text", text: "UNCHECKED_PROGRESS" }], + details: undefined, + }); + const result = await execute(id, args, signal); + return { ...result, details: { diff: "UNCHECKED_DETAILS" } }; + }; + const events: unknown[] = []; + session.subscribe((event) => { + events.push(structuredClone(event)); + }); + await session.prompt("Read notes.txt"); + assert.equal(session.messages.length, 4); + assert.equal( + session.entries.filter((entry) => entry.type === "message").length, + 4, + ); + const saved = (await disk(session)) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(saved.filter((entry) => entry.type === "message").length, 4); + assert.ok(JSON.stringify(events).includes("Approved file")); + for (const snapshot of [ + JSON.stringify(events), + JSON.stringify(session.messages), + await disk(session), + ]) + assert.ok(!snapshot.includes("UNCHECKED")); +}); + +for (const mode of ["steer", "followUp"] as const) { + test(`native ${mode} queue admits expanded input before transcript insertion`, async () => { + let release!: () => void; + let reached!: () => void; + const seen = new Promise((resolve) => { + reached = resolve; + }); + const hold = new Promise((resolve) => { + release = resolve; + }); + const { session, requests } = await fixture( + async (kind, body) => { + if (kind === "assistant_message" && body.text === "First") { + reached(); + await hold; + } + if ( + kind === "user_message" && + String(body.text).includes("SKILL_CANDIDATE") + ) + return { + ...allow, + decision: "replace", + replacement: { + ...body, + text: String(body.text).replace( + "SKILL_CANDIDATE", + "APPROVED_SKILL", + ), + }, + }; + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("First"), answer("Second")], + ); + const running = session.prompt("hello"); + await seen; + await session[mode]("/skill:example"); + assert.ok(!JSON.stringify(session.messages).includes("SKILL_CANDIDATE")); + assert.ok(!(await disk(session)).includes("SKILL_CANDIDATE")); + release(); + await running; + assert.equal(requests.length, 2); + assert.ok(JSON.stringify(requests[1]).includes("APPROVED_SKILL")); + assert.ok(!JSON.stringify(session.entries).includes("SKILL_CANDIDATE")); + assert.equal(session.agent.hasQueuedMessages(), false); + assert.equal( + session.getSteeringMessages().length + + session.getFollowUpMessages().length, + 0, + ); + }); +} + +test("abort settles the native lifecycle without saving late content and permits the next prompt", async () => { + let release!: () => void; + let reached!: () => void; + const seen = new Promise((resolve) => { + reached = resolve; + }); + const hold = new Promise((resolve) => { + release = resolve; + }); + const { session } = await fixture( + async (kind, body) => { + if (kind === "assistant_message" && body.text === "LATE_RESPONSE") { + reached(); + await hold; + } + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("LATE_RESPONSE"), answer("Next reply")], + ); + const running = session.prompt("first"); + await seen; + const rejected = assert.rejects(running); + const abort = session.abort(); + assert.equal(session.agent.state.isStreaming, true); + release(); + await Promise.all([rejected, abort]); + await session.agent.waitForIdle(); + assert.equal(session.isStreaming, false); + assert.ok(!JSON.stringify(session.messages).includes("LATE_RESPONSE")); + assert.ok(!(await disk(session)).includes("LATE_RESPONSE")); + await session.prompt("next"); + assert.ok(JSON.stringify(session.history).includes("Next reply")); +}); + +test("native alternate writes fail closed; /new reuses the admission factory", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-runtime-test-")); + const runtime = await createAdmissionRuntime({ + cwd, + agentDir: join(cwd, "agent"), + sessionDir: join(cwd, "sessions"), + model, + apiKey: "placeholder", + admission: new Admission(async (kind) => + kind === "user_message" ? deny : allow, + ), + }); + await runtime.session.bindExtensions({}); + const before = JSON.stringify(runtime.session.messages); + const entries = runtime.session.sessionManager.getEntries(); + await assert.rejects( + runtime.session.executeBash("touch must-not-exist"), + /not supported/, + ); + assert.throws( + () => + runtime.session.recordBashResult("bad", { + output: "UNCHECKED", + exitCode: 0, + cancelled: false, + truncated: false, + }), + /not supported/, + ); + await assert.rejects( + runtime.session.sendCustomMessage({ + customType: "test", + content: "UNCHECKED", + display: true, + }), + /not supported/, + ); + await assert.rejects( + runtime.session.navigateTree("missing"), + /not supported/, + ); + await assert.rejects(runtime.session.setModel(model), /not supported/); + assert.throws( + () => runtime.session.setSessionName("UNCHECKED"), + /not supported/, + ); + await assert.rejects( + runtime.switchSession("/does/not/exist"), + /not supported/, + ); + await assert.rejects( + runtime.importFromJsonl("/does/not/exist"), + /not supported/, + ); + await assert.rejects(runtime.fork("missing"), /not supported/); + assert.equal(JSON.stringify(runtime.session.messages), before); + assert.deepEqual(runtime.session.sessionManager.getEntries(), entries); + await assert.rejects(readFile(join(cwd, "must-not-exist"))); + const previous = runtime.session; + previous.setAutoCompactionEnabled(false); + previous.setSteeringMode("all"); + previous.setFollowUpMode("all"); + assert.equal(previous.agent.sessionId, previous.sessionId); + await runtime.newSession(); + assert.notEqual(runtime.session, previous); + assert.equal(runtime.session.autoCompactionEnabled, false); + assert.equal(runtime.session.steeringMode, "all"); + assert.equal(runtime.session.followUpMode, "all"); + assert.notEqual(runtime.session.sessionId, previous.sessionId); + assert.equal(runtime.session.agent.sessionId, runtime.session.sessionId); + await runtime.session.bindExtensions({}); + await assert.rejects(runtime.session.prompt("DENIED")); + assert.equal(runtime.session.messages.length, 0); + assert.equal(runtime.session.sessionManager.getEntries().length, 0); + await runtime.dispose(); +}); 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..ca1c4cd0 --- /dev/null +++ b/projects/pi-admission/policy.yaml @@ -0,0 +1,54 @@ +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 } + - { path: /usr/bin/curl } + + 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 } + - { path: /usr/bin/curl } + +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..653e1f75 --- /dev/null +++ b/projects/pi-admission/prepare.py @@ -0,0 +1,351 @@ +# 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", "/usr/bin/curl"], + } + (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() + for directory in ("pi-harness/src", "pi-harness/test"): + shutil.copytree(example / directory, image / directory, dirs_exist_ok=True) + for name in ("package.json", "package-lock.json", "tsconfig.json"): + shutil.copyfile(example / "pi-harness" / name, image / "pi-harness" / name) + # This is one explicit project, not a recursive upload of the operator's cwd. + for name in ("AGENTS.md", "notes.txt", ".pi/skills/review/SKILL.md"): + destination = image / "project" / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(example / "project" / name, destination) + (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/project/.pi/skills/review/SKILL.md b/projects/pi-admission/project/.pi/skills/review/SKILL.md new file mode 100644 index 00000000..0a5ba989 --- /dev/null +++ b/projects/pi-admission/project/.pi/skills/review/SKILL.md @@ -0,0 +1,6 @@ +--- +name: review +description: Review the small demo project using its real files. +--- +Read notes.txt and summarize what you find. This skill itself contains +alice@example.com so its expansion demonstrates email replacement before history insertion. diff --git a/projects/pi-admission/project/AGENTS.md b/projects/pi-admission/project/AGENTS.md new file mode 100644 index 00000000..0e51298d --- /dev/null +++ b/projects/pi-admission/project/AGENTS.md @@ -0,0 +1,4 @@ +# Demo project + +Use the project tools when asked to inspect files. Report observations accurately. +This project demonstrates admission before conversation-history writes. diff --git a/projects/pi-admission/project/notes.txt b/projects/pi-admission/project/notes.txt new file mode 100644 index 00000000..e80c1b34 --- /dev/null +++ b/projects/pi-admission/project/notes.txt @@ -0,0 +1,2 @@ +This is a real file in the sandbox project. +alice@example.com is synthetic data for the example email replacement rule. diff --git a/projects/pi-admission/pyproject.toml b/projects/pi-admission/pyproject.toml new file mode 100644 index 00000000..0500b1e2 --- /dev/null +++ b/projects/pi-admission/pyproject.toml @@ -0,0 +1,30 @@ +[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 = [ + "pytest>=9,<10", + "ruff>=0.12,<1", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[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..ffd4c02b --- /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 curl fd-find ripgrep git iproute2 iptables nftables \ + && 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 +COPY --chown=sandbox:sandbox project/ /sandbox/project/ +RUN mkdir /sandbox/sessions && chown sandbox:sandbox /sandbox/sessions \ + && chmod -R a+rX /app /sandbox/project +ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt +WORKDIR /sandbox/project +USER sandbox diff --git a/projects/pi-admission/tests/test_helpers.py b/projects/pi-admission/tests/test_helpers.py new file mode 100644 index 00000000..e4457af0 --- /dev/null +++ b/projects/pi-admission/tests/test_helpers.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path +from types import ModuleType + +PROJECT = Path(__file__).parents[1] + + +def test_every_demo_action_has_a_side_effect_free_print_mode() -> None: + for action in ( + "prepare", + "serve", + "register", + "unregister", + "registration", + "setup", + "launch", + "verify", + "cleanup", + ): + result = subprocess.run( + ["bash", "demo.sh", "--print", action], + cwd=PROJECT, + capture_output=True, + text=True, + check=True, + ) + assert "your-provider-key" not in result.stdout + + +def test_gateway_helper_removes_only_its_owned_registration(tmp_path: Path) -> None: + module = _load("gateway_registration", PROJECT / "gateway-registration.py") + config = tmp_path / "gateway.toml" + config.write_text( + """[openshell] +version = 1 + +[[openshell.supervisor.middleware]] +name = "unrelated" +grpc_endpoint = "https://elsewhere:50051" + +[[openshell.supervisor.middleware]] +name = "pi-admission" +grpc_endpoint = "https://service:50051" +""" + ) + module.remove_gateway_config(config, middleware_name="pi-admission") + updated = config.read_text() + assert 'name = "unrelated"' in updated + assert 'name = "pi-admission"' not in updated + + +def test_model_selection_keeps_one_native_pi_model(tmp_path: Path) -> None: + module = _load("prepare", PROJECT / "prepare.py") + catalog = tmp_path / "models.json" + catalog.write_text( + """{"providers":{"demo":{"api":"openai-completions","baseUrl":"https://api.example.test/v1","models":[{"id":"one"},{"id":"two"}]}}}""" + ) + selected, identity, endpoint = module.select_model(catalog, "demo/two") + assert identity == {"provider": "demo", "id": "two"} + assert selected["providers"]["demo"]["models"] == [{"id": "two"}] + assert endpoint == "https://api.example.test/v1" + + +def _load(name: str, path: Path) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module diff --git a/projects/pi-admission/uv.lock b/projects/pi-admission/uv.lock new file mode 100644 index 00000000..be62c570 --- /dev/null +++ b/projects/pi-admission/uv.lock @@ -0,0 +1,350 @@ +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 = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.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 = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[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 = "pytest" }, + { 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 = "pytest", specifier = ">=9,<10" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[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 = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[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 = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[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" }, +]