diff --git a/.gitignore b/.gitignore index e24654d..9dd8031 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ vector-store/ # own scratch dir so it never lands here, but a maintainer querying a CPG by hand from the repo root # will create one. workspace/ + +# MkDocs build output (mkdocs build) +site/ diff --git a/README.md b/README.md index 81d5f6e..6dbb282 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,8 @@ shell, Docker or a free-form command. ## Further reading +Documentation site with flow diagrams (`docs/`, `mkdocs.yml`): `uv sync --extra docs && uv run mkdocs serve`. + - [docs/architecture.md](docs/architecture.md): the scan pipeline, evidence ladder, CWE policy and project score, and the self-improving loops. - [docs/evaluation.md](docs/evaluation.md): independent populations, the qualification gates, diff --git a/docs/decision-engine.md b/docs/decision-engine.md index a92209b..401986a 100644 --- a/docs/decision-engine.md +++ b/docs/decision-engine.md @@ -80,6 +80,6 @@ v3 check are still ahead. | `ousast learn curve --program ID` | the metric over memory size on a fixed candidate subset | | `ousast learn audit-leaks` | how well each signal alone separates the sides of a pair (no model) | -The memory store is the plane's (`OUSAST_MEMORY`; see [ops/ax/README.md](../ops/ax/README.md)). +The memory store is the plane's (`OUSAST_MEMORY`; see [ops/ax/README.md](ops/ax/README.md)). The harvest that produced the plane signals runs as plane Runs generated by `ousast plane harvest` (`benchmarks/measurements/2026-10-01-decision-engine-harvest/record.json`). diff --git a/docs/evaluation.md b/docs/evaluation.md index ad76b4c..63b86d4 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -49,6 +49,43 @@ v3 is reserved: development and analysis work never reads its cases, and `tests/test_independent_population.py` fails when anything in the development tree (a recipe, catalog, manifest or measurement) names one of its repositories. +```mermaid +stateDiagram-v2 + [*] --> Frozen: cases selected and frozen before any scan + Frozen --> Scored: scored once under the pre-registered protocol + Scored --> Spent: result recorded + Spent --> [*]: may inform changes, can no longer qualify anything + note right of Frozen + v3 (PHP, 15 cases) waits here as the one-time final check, + run once after a prediction is committed + end note + note right of Spent + v1 and v2 are spent + end note +``` + +## Folds: how learned decisions are kept out of their own training data + +The decision engine is evaluated on repositories it was not shown (`src/openultrasast/learn/folds.py`). +Every split is by repository group (the label builder's normalised `owner/name`, merged across +URLs, forks and shared advisories), across all corpora, seeded and deterministic. + +```mermaid +flowchart TD + groups["All repository groups with labels"] --> c["Compile split: 25% of groups, never evaluated"] + groups --> rest["The other 75%"] + c --> boot["C_boot 60%: demonstrations"] + c --> val["C_val 40%: instruction scoring"] + rest --> outer["Outer grouped 5-fold: calibration cross-fitted, operating points nested"] + rest --> loso["Leave-one-source-out: a source held out whole"] + rest --> lofo["Leave-one-framework-out: frameworks with at least 10 groups"] +``` + +`assert_disjoint` runs before a compile: the compile split and the evaluation folds share no +group. A framework with fewer than 10 groups gets `insufficient data` instead of a fold. As of +2026-10-02 only the outer folds of the injection family have been run +([decision-engine.md](decision-engine.md#first-measured-slice-2026-10-01)). + ## The plane increment The first increment of the agentic plane ran the model-driven pipeline's validation set (46 diff --git a/docs/examples.md b/docs/examples.md index 926b8ed..0d642b1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -69,7 +69,7 @@ uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml ## 7. Running the agentic plane Model work over whole repositories runs as a Run manifest on ax, one isolated actor -per task (bring-up: [ops/ax/README.md](../ops/ax/README.md)). The provider key is +per task (bring-up: [ops/ax/README.md](ops/ax/README.md)). The provider key is read from the environment (or `.env`, which never overrides an exported variable) by `ousast` and sent only in each task's start request. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d6f7bce --- /dev/null +++ b/docs/index.md @@ -0,0 +1,54 @@ +# OpenUltraSAST + +OpenUltraSAST is an experimental static security analyser meant to work as a safety net on +repositories it has never seen: it scans a checkout, or the commits a `git push` would publish, +and separates what it can prove from what it merely suspects. A scan that could not look at +something records a degradation instead of reporting a clean result. + +**State as of 2026-10-02 (v1.2.0-alpha.1):** the pre-push safety net is **NO-GO** for rollout. +No hook capability is enabled, and no independent population has passed the qualification gates +(see [Evaluation](evaluation.md)). + +## Three layers + +| Layer | What it is | Where | +| --- | --- | --- | +| **Scanners** | `ousast scan` (`quick`, `standard`, `deep`) and `ousast pre-push`: language-scoped pattern rules, a Joern code property graph engine, verification and scoring. Deterministic in `quick`; no model is needed for a scan. | [Scanning](scanning.md), [Architecture](architecture.md) | +| **The agentic plane on ax** | Model-driven work over whole repositories, run as a DAG of tasks on google/ax, one isolated actor per task. What the runs learn is kept in a memory store. Maintainer surface: `ousast plane`. | [The plane on ax](plane.md), [Memory](memory.md) | +| **The decision engine** | A learned classifier that weighs every instrument's signals against similar labelled cases from memory. **In development, not adopted**: no scan, pre-push check or report uses it. Maintainer surface: `ousast learn`. | [Memory and detection](memory-and-detection.md), [Decision engine](decision-engine.md) | + +## How they connect + +```mermaid +flowchart LR + repo["Repository or push delta"] --> scan["Scanners: quick rules, Joern engine, verify, score"] + scan --> report["Report: findings with evidence, SARIF, score"] + scan -- "alerts on vulnerable and fixed pins" --> plane["Plane on ax: repo-facts, verify a/b/c, agree, features"] + plane -- "remember" --> mem[("Memory store: rows, facts, examples, blobs")] + mem -- "improve --memory: rule-status proposals" --> gate{"Validator and detection gate"} + gate -- "accepted round" --> ledger["Ruleset ledger: rule status"] + ledger --> scan + labels["Labels from ground truth"] --> mem + mem -- "retrieval of similar labelled cases" --> engine["Decision engine: compiled AI classifier"] + engine -. "intended, not adopted: BLOCK / ADVISORY" .-> report +``` + +- The **scanners** produce findings and, inside plane Runs, the `alerts` that become memory rows. +- The **plane** runs model work (verify passes, roles) and model-free bookkeeping (facts, agreement, + feature records) per case; its `remember` task turns a case's artifacts into rows of the + [memory store](memory.md). +- Memory feeds two consumers. `ousast improve --memory` turns stored rows into rule-status + proposals that pass through the same validator and gate as every other edit. The decision engine + retrieves labelled examples from the same store. +- The decision engine's output (dotted line) is the intended path. Nothing user-facing reads it + today. + +## Where to go next + +- Run a scan: [Examples](examples.md), and the commands in [Scanning](scanning.md). +- Understand a finding's evidence and score: [Architecture](architecture.md). +- Operate the plane and its store: [ax on this host](ops/ax/README.md) and + [RustFS setup](rustfs.md). +- Trust boundaries and hardening: [Threat model](threat-model.md). + +Every number in these pages is quoted from a committed record whose path is given next to it. diff --git a/docs/memory-and-detection.md b/docs/memory-and-detection.md new file mode 100644 index 0000000..77ee9d7 --- /dev/null +++ b/docs/memory-and-detection.md @@ -0,0 +1,156 @@ +# Memory and detection + +How what the system remembers is meant to turn into better decisions on code it has not seen. +This is the design of the learned decision engine (`src/openultrasast/learn/`), which is **in +development and not adopted**: no scan, pre-push check or report uses it today. Every user-facing +decision is still the deterministic one described in [Scanning](scanning.md) and +[Architecture](architecture.md). The engine's commands and status are on +[Decision engine](decision-engine.md); the store itself is on [Memory](memory.md). + +## From instruments to a decision + +```mermaid +flowchart TD + inst["Instruments: quick rules, Joern engine, verify passes and agree, model roles, repo facts"] --> feat["Feature record per candidate: path, function, family"] + feat --> prof["Input profile v1: engine and function length withheld"] + lab["Labels from ground truth: pairs, populations v1/v2, advisory fixes, adjudications"] --> ex["Example rows in memory: label + features + excerpt"] + prof --> ret["Retrieval: signal profile, then code embeddings"] + ex --> ret + ret --> bnd{"Evaluation boundary"} + bnd --> prog["Compiled AI classifier: instruction, demonstrations, up to 6 examples, candidate"] + prog --> score["Score s: mean over k samples"] + score --> cal["Platt calibration, cross-fitted on held-out repositories"] + cal --> op{"Operating point"} + op -- "calibration holds and precision reachable" --> block["BLOCK"] + op -- "otherwise" --> adv["ADVISORY"] +``` + +The intended place for these decisions is changed code: `learn/features.py` builds feature records +for a whole-repository scan or for a base..head delta (`build_for_scan` with `Delta`), the same +delta `ousast pre-push` checks. Integration into scan and pre-push is still ahead. + +## 1. Instruments produce signals, not verdicts + +Each instrument contributes one block to a candidate's feature record (`learn/schema.py`, +`INSTRUMENTS`): `language`, `quick` (quick rules), `engine` (the Joern model layer), `facts` (repo +facts, such as callers and function span), `source`, `entry_points`, `roles` (model source, sink +and sanitizer roles), `delta`, `verify` and `agree` (the plane's verify passes and their +agreement), and `model_sinks`. A candidate is `(path, function, family)` of one repository and pin +where at least one instrument emitted a signal of that family (`learn/features.py`). + +An instrument that did not run, had no coverage or failed records that state (`none`, `failed`, +`not_applicable`), never a zero, so "silent" and "did not look" stay distinguishable. None of the +instruments decides alone. + +**Why profile v1 withholds the engine and function length.** A leak audit +(`ousast learn audit-leaks`, `benchmarks/measurements/2026-10-01-decision-engine-leak-audit/`) +found that both separate the vulnerable and fixed sides of a pair by construction: a fix removes +a sink, and a guard lengthens the function. That holds on the pair corpus, not on unseen code, so +`INPUT_PROFILES["v1"]` drops the `engine` block and `facts.function_lines` from prompts and from +the retrieval distance. + +## 2. Labels come from ground truth only + +`ousast learn labels` (`learn/labels.py`) reads only the sources on a fail-closed allow-list +(`learn/sources.toml`): `population-v1`, `population-v2`, `dev-php` (reviewed recipe sites), +`pairs`, `advisory-fixes`, `adjudications` and `assumed_benign`. A file the list does not name is +refused before it is opened; the reserved population v3 is excluded unread. + +- **Positives** are declared sites and the vulnerable side of a fix; **negatives** are the same + function on the fixed side where the fix changed it, and recorded `False`/`FP` adjudications. + A vulnerable-pin candidate matching no site is unlabelled, never negative. +- **Assumed benign** (maintainer decision 2026-09-30): ordinary non-security commits of the training + repositories, at least 90 days from any fix, with no later security commit touching the same + files. They are their own source with weight 0.5, conditional on a signal at the tip and none at + the base, and reported separately, never merged with verified negatives. +- **Plane verdicts are features, not labels.** "Agreed", "rejected" or "disputed" is the output of + the verify instrument; it enters the record as the `verify` and `agree` blocks. Using it as a + label would teach the engine to reproduce the plane, mistakes included, so "rejected by the + plane" is never a negative (`learn/labels.py`: "A plane verdict ... is never a label"). + +`ousast learn memory build` joins each label with the candidate's feature record and an excerpt of +its function and stores one `example` row (no rationale) plus the excerpt blob. + +## 3. Retrieval of similar labelled cases + +`learn/retrieve.py`, no model: + +1. **Signal profile.** A Gower distance over the allow-listed features of the active profile, + per instrument block, with missing values explicit (a different state counts as distance 1). + The nearest 40 examples of the candidate's family and the nearest 40 of other families go on. +2. **Code embeddings.** A cosine re-rank of the excerpts' embeddings (OpenAI + `text-embedding-3-small` through OpenRouter, 1,536 dimensions, cached by excerpt hash): + `r = 0.5 (1 - D) + 0.5 cos`. Without the candidate's vector the retrieval is signals only. +3. **Balance.** Six examples: at most half per label, at most 2 per repository group, at most 2 + contrast examples from other families. + +**The evaluation boundary.** `eligible()` is the only way an example enters a prompt, for retrieved +examples and compiled demonstrations alike: never the candidate's own repository group, never a +group the fold evaluates, never the held-out source or framework, and (outside deployment) never +a near-duplicate of the candidate (cosine >= 0.98). The program repeats the check on the ids it +actually rendered before any call. + +## 4. The AI classifier, compiled + +`learn/program.py` and `learn/compile.py`, DSPy-style and in-house. The prompt is the instruction +plus the compiled demonstrations (a prefix identical for every candidate, so the provider's prefix +cache hits), then the retrieved examples, then the candidate's excerpt, signals, roles and family. +The model (`deepseek-flash`) answers `vulnerable`, `not_vulnerable` or `unsure` with a confidence +and a rationale that must cite a line of the code. It is sampled `k` times; the score `s` is the +mean over samples, and a majority `unsure` can never BLOCK. Compilation bootstraps demonstrations +and searches instructions on a compile split of repository groups, scored by a pre-registered +balanced Brier score (`learn/compile.toml`); the program is stored as `programs/.json`. + +## 5. Calibration and the two operating points + +`learn/calibrate.py`: Platt scaling of `s`, cross-fitted so that no map is checked on the fold it +was fitted on. Calibration **holds** when the slope interval contains 1, the intercept interval +contains 0 and ECE <= 0.05. **BLOCK** is offered only when calibration holds and a fold reaches the +required precision; otherwise the engine offers **ADVISORY** only. + +## The loop: memory improves decisions without retraining + +```mermaid +flowchart LR + new["New labels and adjudications"] --> labels["ousast learn labels"] + labels --> build["ousast learn memory build, embed"] + build --> mem[("Memory store: example rows")] + mem --> retr["Retrieved per candidate at decision time"] + retr --> prog["Same compiled program"] + prog --> dec["Decision"] + runs["Plane runs: alert, verdict, coverage rows"] --> mem2[("Memory store: run rows")] + mem2 --> imp["ousast improve --memory: M1, M2 proposals"] + imp --> gate{"Unchanged validator and gate"} + gate -- "accepted round" --> ledger["Rule status in the ledger"] +``` + +- **Decisions.** The compiled part of the program is its instruction and demonstrations; the + examples are retrieved from memory for each candidate. A new `example` row is therefore eligible + for retrieval in the next decision without recompiling the program, subject to the same + evaluation boundary. `ousast learn curve` measures the metric over memory size on a fixed + candidate subset. +- **Rules.** `ousast improve --memory` reads the run rows (alerts, verdicts, coverage) and proposes + rule-status edits only (shadow a rule that is repeatedly false across repositories; re-enable a + shadow rule that keeps hitting agreed declared sites). Rows from holdout pairs, the gated + manifest's own cases and any `--qualify-population` are dropped first, and every proposal goes + through the same validator and gate as any other edit (`improve/memory.py`). + +## What the first slice measured + +Record: `benchmarks/measurements/2026-10-01-decision-engine-injection-slice/record.json` (counts +only). Family **injection**; memory of 724 static examples from all families; input profile v1; +framework priors off; out-of-repository evaluation (compile split never evaluated, outer grouped +5-fold, calibration cross-fitted, operating points nested); 95% repository-cluster bootstrap +intervals. + +| Measure | Value | What it means | +| --- | --- | --- | +| Candidates | 110 in 29 repository groups, 58 positive | the positive share is 58/110 = 0.53 | +| AUC of the score `s` | 0.74 (0.65 - 0.85) | the score ranks a vulnerable candidate above a non-vulnerable one about three times in four: there is ranking signal | +| ADVISORY | recall 0.90 (0.82 - 0.97), precision 0.54 (0.44 - 0.65) | precision is at the positive share (0.53), so at this threshold ADVISORY is **not discriminating**: it flags nearly as indiscriminately as flagging everything would | +| BLOCK | not offered | calibration does not hold (ECE 0.064 > 0.05), and every outer fold reported `precision_unreachable` | + +**The engine is not adopted.** These numbers come from one family and one slice; the other +families, the leave-one-source-out and leave-one-framework-out evaluations, integration into scan +and pre-push, and the one-time check on population v3 are still ahead +([Decision engine](decision-engine.md#first-measured-slice-2026-10-01)). diff --git a/docs/memory.md b/docs/memory.md new file mode 100644 index 0000000..e9f4737 --- /dev/null +++ b/docs/memory.md @@ -0,0 +1,131 @@ +# Memory + +One store, keyed by repository and pin, holds what plane runs and the decision engine learned. +The code is `src/openultrasast/plane/memory.py`; the same layout serves both backends. +How the stored rows feed detection is on [Memory and detection](memory-and-detection.md). + +## What is stored + +``` +index.jsonl one row per ingested (run, task): sha256 of its rows, row count +facts/.json repository facts by content (identical facts stored once) +repos/____/.jsonl the rows of one repository + 40-hex pin, sorted by id +excerpts/.txt code excerpts, by content +embeddings//.json an excerpt's embedding vector, per embedding model +responses/.json cached model responses +programs/.json compiled decision-engine programs +``` + +**Rows.** Every row carries `id`, `kind`, `repo`, `pin`, `run`, `task`, `population`, `split` +and `image` (`ROW_FIELDS`); `id` is the sha256 of (kind, run, task, subject), so a repeated row +replaces itself. The kinds (`KINDS`) and who writes them: + +| Kind | Written by | Holds | +| --- | --- | --- | +| `facts` | `remember` task | the facts entry of a case (points at `facts/.json`; reused by memory key) | +| `verdict` | `remember` task | per candidate: each verify pass's answer, the final outcome, whether a tie-break ran, cost | +| `unit_cost` | `remember` task | per verify pass and file: usd, calls and token usage | +| `alert` | `remember` task | a quick-scan or engine alert on the vulnerable or fixed pin (loop Runs) | +| `coverage` | `remember` task | per language and pin: whether any rule could have fired there | +| `features` | `remember` task, from the `features` task | one feature record per candidate and family | +| `proposal_outcome` | `ousast improve --memory` (not with `--dry-run`), and the loop's `improve` step | what happened to each memory proposal in a round | +| `example` | `ousast learn memory build` | a labelled case: label + feature record + excerpt sha (no rationale) | +| `label`, `decision`, `experiment`, `arm_outcome`, `experiment_result` | reserved for later decision-engine tasks | declared in `KINDS`; not written by the code on main yet | + +**Blobs** (`put_blob`) are content-addressed: a 64-hex name under a fixed prefix. Excerpts are +written by `learn memory build` (`learn/examples.py`), embeddings by `learn memory embed` +(`learn/embeddings.py`, keyed by excerpt sha so an excerpt is embedded once), responses by the +program's caller (`learn/program.py`, keyed by the sha256 of model, parameters digest, messages, +temperature, sample and format, so a rerun replays at no cost), and compiled programs by +`learn compile` (`learn/compile.py`). + +## Write path + +```mermaid +flowchart LR + tasks["Plane tasks: facts, verify a/b/c, agree, alerts, features"] --> rem["remember task: memory.jsonl"] + rem --> recv["Delivered to the host with the run's artifacts"] + recv --> ingest["ingest after ousast plane run, or ousast plane remember RUN"] + loop["Loop improve step: proposal_outcome"] --> ingest + improve["ousast improve --memory: proposal_outcome"] --> put + build["ousast learn memory build: labels + features + excerpts"] --> put["put_rows, put_blob"] + embed["ousast learn memory embed"] --> put + compile["ousast learn compile, evaluate"] --> put + ingest --> idx{"index.jsonl: same run, task and sha256?"} + idx -- "yes" --> skip["Skipped"] + idx -- "no" --> put + put --> store[("Memory store")] +``` + +- `ingest` validates every row first: a malformed file fails before anything is written. +- Rows are merged into their repository + pin object by `id`. Every object is written whole and the + index last, so an interrupted ingest leaves nothing a repeat would not repair. +- Each row object carries tags (`TAG_FIELDS`: `repo`, `pin`, `kind`, `family`, `run`, + `population`, `split`) listing the distinct values of its rows (`*` when they exceed 256 + characters). +- The plane's store failures never change a run's result: an ingest can be repeated by hand + (`cli.py`, `_plane_memory`). + +## Read path + +```mermaid +flowchart LR + store[("Memory store")] --> seed["seed before a Run"] + seed --> reuse["Facts reused by memory key: repo, pin, candidates, runner image"] + seed --> snap["memory-snapshot: rows after the train-on-test guard"] + store --> examples["learn: load example rows, excerpts, embeddings"] + examples --> retr["Retrieval of similar labelled cases"] + store --> responses["Response cache: replay at no cost"] + store --> imp["ousast improve --memory: alert, verdict, coverage rows"] + imp --> guard["Train-on-test guard"] + guard --> rules["Rules M1, M2 to rule-status proposals"] + rules --> gate{"Unchanged validator and gate"} +``` + +- **Seed** (`memory.seed`): a Run task whose `openultrasast.io/memory-key` matches stored facts gets + them and is marked done; a changed pin, candidate set or runner image finds nothing and + recomputes. The loop's `memory-snapshot` is seeded the same way, because a sandboxed task cannot + read the store. +- **Retrieval** (`learn/retrieve.py`): see + [Memory and detection](memory-and-detection.md#3-retrieval-of-similar-labelled-cases). +- **`improve --memory`** (`improve/memory.py`): rows from holdout pairs, from the gated manifest's + own cases and from any `--qualify-population` are dropped before any rule sees them. See + [Architecture](architecture.md#proposals-from-plane-memory). + +## Backends: FileStore and the S3 store + +`OUSAST_MEMORY` selects the backend (`open_store`): + +| | `FileStore` | `MinioStore` (S3: RustFS or MinIO) | +| --- | --- | --- | +| Selected by | `file:///path`, or nothing (default `$OUSAST_RESULTS/plane/memory`) | `minio://[/]` | +| Needs | a local disk; refuses to write below 1 GiB free | the `minio` extra, `MINIO_*` settings from `.env` or the environment | +| Filtered reads | read and filter locally | S3 Select pushdown (`SelectObjectContent` over JSON Lines); kind filter by object tag | +| Object version cited by provenance | the file's content hash | the bucket's object version id | +| Bucket setup | none | done once by an admin, verified at every start ([RustFS setup](rustfs.md)) | + +### Verify at startup, no fallback + +The S3 store never configures its bucket. Each time a `MinioStore` is opened for a bucket and +prefix (once per process), `verify_bucket()` checks, using reads plus one small probe object at +`/_probe/select.jsonl`: + +1. versioning is `Enabled` (provenance cites object versions); +2. an enabled lifecycle rule expires `/runs/` after a number of days, as a plain prefix + filter, and no enabled rule expires the whole store; +3. S3 Select answers a probe query over JSON Lines; +4. the probe's object tags are readable. + +Anything missing is named, with the admin command that fixes it, in one `MemoryStoreError`, and +the store does not start. There is no local or fetch-and-filter fallback: a filtered read that +S3 Select cannot answer raises a `MemoryStoreError` instead of returning "no rows". The exact +messages are listed on [RustFS setup](rustfs.md#what-the-store-verifies-at-startup). + +### Queryable fields per kind (in progress) + +RustFS's Select infers an object's JSON schema from its leading rows, so a `where` field missing +there fails with `EvaluatorBindingDoesNotExist` even when a later row has it (measured +2026-10-02; `memory.py`, `MinioStore._select`). On main today the store turns that failure into a +`MemoryStoreError` naming the field, never into an empty answer. A fixed set of queryable fields +per row kind, so that every filtered field is present in every row of its kind, is in progress +and not on main as of this page. diff --git a/docs/ops/README.md b/docs/ops/README.md new file mode 100644 index 0000000..ac79737 --- /dev/null +++ b/docs/ops/README.md @@ -0,0 +1,3 @@ +--8<-- "ops/README.md" + +*Source: `ops/README.md` in the repository; this page includes it when the docs site is built.* diff --git a/docs/ops/ax/README.md b/docs/ops/ax/README.md new file mode 100644 index 0000000..d6c8bc4 --- /dev/null +++ b/docs/ops/ax/README.md @@ -0,0 +1,10 @@ +# ax on this host + +The agentic plane runs every task on google/ax over Agent Substrate in a single-node kind cluster. ax is the +only executor; `ousast plane run` submits, resumes, starts and collects. The flow is drawn in +[The plane on ax](../../plane.md); the bucket setup for the S3 memory store is in +[RustFS setup](../../rustfs.md). + +--8<-- "ops/ax/README.md:5" + +*Source: `ops/ax/README.md` in the repository, from its line 5 on; this page includes it when the docs site is built.* diff --git a/docs/plane.md b/docs/plane.md new file mode 100644 index 0000000..8da1b26 --- /dev/null +++ b/docs/plane.md @@ -0,0 +1,144 @@ +# The plane on ax + +Model-driven work over whole repositories runs on a separate agentic plane: a **Run** +(`plane/runs/*.yaml`, kind `openultrasast.io/v1alpha1 Run`) is a DAG of steps, and each step is +an ax **Task** (`plane/tasks/`) that binds its Workspaces (`plane/workspaces/`), at most one ax +**Model** (`plane/models/`: `deepseek-flash` for chat, `openrouter-embedding` for embeddings) and +its own `budget: {usd, calls}`. ax (google/ax over Agent Substrate, in a single-node kind cluster +on the maintainer's host) is the only executor. Bring-up and the host's lessons are in +[ax on this host](ops/ax/README.md); this page draws the flow. + +Sources: `src/openultrasast/plane/reconciler.py`, `router.py`, `egress.py`, `runner.py`, +`generate.py`, `memory.py`, and `src/openultrasast/plane/tasks/`. + +## One task, end to end + +`ousast plane run RUN.yaml` (the reconciler) does this for every ready task; `--workers` bounds +the tasks in flight, and a rerun skips tasks already done. + +```mermaid +sequenceDiagram + autonumber + participant R as Reconciler (ousast plane run) + participant AX as ax control plane + participant EG as Egress gateway (atenet-egress) + participant RT as Substrate router (atenet-router) + participant RN as Runner (actor in gVisor) + participant RC as Artifact receiver (host) + R->>R: render Model, Workspaces, Task to task.yaml, read the Model's key from env or .env + R->>AX: ax apply -f task.yaml (creates the actor, egress denied) + R->>EG: EgressPolicy for this actor only + R->>AX: ax resume task (retried while the golden snapshot builds) + loop until phase Running + R->>AX: ax get task + end + R->>RT: POST /ousast/v1/start {run, task, credentials} + RT->>RN: forwarded to this task's actor only + RN->>RN: materialise Workspaces (git at pinned commits), after the start + RN->>RC: GET /inputs/producer/artifact (through the gateway) + RN->>RN: python -m openultrasast.plane.tasks.MODULE (key in its env only, metered budget) + RN->>RC: POST tar of OUSAST_OUTPUT_DIR (through the gateway) = completion + RN->>RN: write the completion marker + R->>AX: ax delete task, then delete the EgressPolicy + R->>R: summary.json into state.json, plane status writes attribution.json +``` + +What each step guarantees: + +- **Credentials** (`router.py`, `runner.py`). The reconciler reads the variable the Model's + `secretKey.key` names from the operator's environment or `.env` (which never overrides an + exported variable). The key exists only in the start request; the runner keeps it in memory, + passes it to the command's environment, redacts it from echoed stderr and never writes it. +- **Egress** (`egress.py`). One EgressPolicy per actor, deny by default, hostnames only: plain + HTTP to the artifact receiver, TLS passthrough to the source hosts of the bound Workspaces and + to the hosts the bound Model declares (`openultrasast.io/egress-hosts`). It is written between + `ax apply` and the resume, and deleted with the actor. +- **Inputs** (`reconciler.py`). Artifacts other tasks produced are never rendered into the + manifest; the runner fetches them from the receiver after the start, and the receiver serves a + running task its declared inputs only. +- **Completion** (`runner.py`). ax has no Completed phase: the runner's delivery of the output tar + is the task's completion. A completion marker stops a resumed actor from repeating billed work; + when the marker says the delivery failed, a later boot retries the delivery alone. +- **Budgets** (`plane/budget.py`). The metered client refuses the next call once a task's `usd` or + `calls` ceiling is reached; the task ends `unfinished` and resumes on a rerun with a larger + budget. Model-free tasks run with `{usd: 0, calls: 0}`, so a stray call fails loudly. +- **Attribution.** `ousast plane status RUN` prints per task its status, `calls`, `prompt`, + `cache_hit` and `output` tokens and `usd` from the tasks' `summary.json`, and writes + `attribution.json` in the run directory (`$OUSAST_RESULTS/plane/RUN/`, default + `~/ousast-results/plane/`). + +!!! note "The golden snapshot" + Agent Substrate boots every new actor template once as a *golden* actor to snapshot it, with + the same image, Task and environment, and inside the sandbox nothing tells that boot from the + real one. So the runner's command never starts by itself: it waits for the start request, + which the reconciler sends through `atenet-router` to the task's actor, never to the golden + one. Workspaces are prepared only after the start, so the golden boot touches no network and a + started actor already runs under its egress policy (a clone at boot failed on the live cluster + while the actor was being restored, 2026-09-29; `runner.py`, `_run_once`). The first resume of + a new image can time out while the snapshot is built, which is why the reconciler retries it + for up to `OUSAST_RESUME_TIMEOUT` seconds (900). + +## The Runs: validation and loop + +`ousast plane workspaces POPULATION --validation-set ...` (`generate.py`) writes one chain per +case; `--loop` adds the `alerts` step per case and the loop's singleton steps. Every step except +the verify passes (and `roles` in harvest Runs) is model-free. + +### Per case + +```mermaid +flowchart LR + facts["facts: repo-facts"] --> va["va: verify pass a"] + facts --> vb["vb: verify pass b"] + va --> agree["agree: a vs b"] + vb --> agree + agree -- "disputed only" --> vc["vc: verify pass c"] + va --> final["final: 2-of-3"] + vb --> final + vc --> final + final --> features["features: one record per candidate"] + alerts["alerts: quick scan on vulnerable and fixed pins (--loop)"] -.-> features + features --> remember["remember: memory.jsonl"] + alerts -.-> remember + final --> remember +``` + +### The loop (with `--loop`) + +```mermaid +flowchart LR + store[("Memory store")] -- "seeded before the Run, after the train-on-test guard" --> snap["memory-snapshot"] + rem["remember of every case"] --> measure["loop-measure"] + snap --> measure + measure --> propose["loop-propose: rules M1 and M2, M3 advisory"] + snap -- "index.json" --> propose + propose --> improve["loop-improve: one evolve round, unchanged validator and gate"] + improve --> outcome["gate.json, journal.json, and rule_policy.json only if accepted"] +``` + +- **Before the Run** `ousast plane run` seeds from the store (`memory.seed`): a task whose + `openultrasast.io/memory-key` (repository, pin, candidates digest, runner image digest) has + stored facts gets them and is marked done, and the sandboxed `memory-snapshot` task gets the + store's rows after the guard. **After the Run** it ingests every delivered `memory.jsonl` + (`ousast plane remember RUN` repeats this by hand). See [Memory](memory.md). +- The loop's rules and its guard are described in + [Architecture](architecture.md#proposals-from-plane-memory). Adopting an accepted ledger stays a + maintainer commit (`src/openultrasast/plane/tasks/loop.py`). +- `ousast plane harvest` writes the decision engine's harvest Runs (verify a/b with agree, and + model `roles`); `ousast plane alerts-engine` produces a Run's `alerts` from the Joern engine + image on the host for PHP and other languages quick mode does not cover. + +## Tasks + +| Task | Model | Does | +| --- | --- | --- | +| `repo-facts` | none | functions per product file, and each candidate's call sites in other files | +| `verify` (passes a, b, c) | `deepseek-flash` | batched tool hunt per file over the candidates, with their known callers | +| `agree` | none | a and b agreed or disputed; after pass c on the disputed, 2-of-3 (the step `final`) | +| `features` | none | one feature record per candidate for the decision engine | +| `remember` | none | the case's artifacts as memory rows | +| `alerts` | none | the quick scan on the vulnerable and fixed pins (loop Runs) | +| `loop` | none | the steps `snapshot`, `measure`, `propose`, `improve` | +| `roles` | `deepseek-flash` | per-repository source, sink and sanitizer roles without a vocabulary | + +Source: [ax on this host](ops/ax/README.md#tasks). diff --git a/docs/rustfs.md b/docs/rustfs.md new file mode 100644 index 0000000..7909d6b --- /dev/null +++ b/docs/rustfs.md @@ -0,0 +1,184 @@ +# RustFS setup for the memory store + +The plane's memory store can live in an S3-compatible bucket: `OUSAST_MEMORY=minio://sast-memory` +selects `MinioStore` (`src/openultrasast/plane/memory.py`), which works with RustFS and MinIO. The +maintainer's server is RustFS at `files.because-security.com`, bucket `sast-memory`, store at the +bucket root. What the store keeps there is described on [Memory](memory.md). + +**The store never configures its bucket.** An admin sets the bucket up once with admin +credentials. The agent account the store runs as can read the bucket's settings but not change +them, and reads and writes objects freely. Every time the store opens, it checks the setup and +refuses to start if anything is missing. + +```mermaid +flowchart LR + admin["Admin, once: versioning on, runs/ expiry rule"] --> bucket[("Bucket sast-memory")] + agent["Agent account: read settings, read and write objects and tags"] --> verify{"verify_bucket at startup"} + bucket --> verify + verify -- "all present" --> store["MinioStore serves reads and writes"] + verify -- "anything missing" --> refuse["MemoryStoreError naming each missing piece and its admin command"] +``` + +## One-time admin steps + +Run these with **admin** credentials (not the agent's). Both use the AWS CLI against the RustFS +endpoint. + +**1. Turn versioning on.** Provenance cites object version ids. + +```bash +aws s3api put-bucket-versioning \ + --endpoint-url https://files.because-security.com \ + --bucket sast-memory \ + --versioning-configuration Status=Enabled +``` + +**2. Expire raw run outputs under `runs/` after 30 days.** Everything else (`repos/`, `facts/`, +`index.jsonl` and the blobs) is kept, so the rule must be a plain prefix filter on `runs/`. + +```bash +aws s3api put-bucket-lifecycle-configuration \ + --endpoint-url https://files.because-security.com \ + --bucket sast-memory \ + --lifecycle-configuration '{"Rules":[{"ID":"ousast-runs-expiry","Status":"Enabled","Filter":{"Prefix":"runs/"},"Expiration":{"Days":30}}]}' +``` + +`put-bucket-lifecycle-configuration` **replaces every rule on the bucket**. If the bucket already +has rules, merge this one into them first. The rule id `ousast-runs-expiry` and the 30 days are the +store's defaults (`RUNS_RULE_ID`, `RUNS_EXPIRE_DAYS`); the store accepts any enabled, untagged +prefix rule on `runs/` with a number of days. + +To read the result back: + +```bash +aws s3api get-bucket-versioning --endpoint-url https://files.because-security.com --bucket sast-memory +aws s3api get-bucket-lifecycle-configuration --endpoint-url https://files.because-security.com --bucket sast-memory +``` + +## The agent policy + +Attach this policy to the agent account (the account whose keys go into `.env`). It is the +complete policy: nothing else is needed, and it grants no `Put*` on versioning or lifecycle. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "BucketSettingsReadOnly", + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:GetLifecycleConfiguration" + ], + "Resource": ["arn:aws:s3:::sast-memory"] + }, + { + "Sid": "ObjectsReadWrite", + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:GetObjectTagging", + "s3:PutObjectTagging", + "s3:GetObjectVersionTagging" + ], + "Resource": ["arn:aws:s3:::sast-memory/*"] + } + ] +} +``` + +| Statement | Resource | In one line | +| --- | --- | --- | +| `BucketSettingsReadOnly` | `arn:aws:s3:::sast-memory` | The agent can list the bucket and its versions and *read* its location, versioning and lifecycle settings, which is what the startup check needs; it cannot change them. | +| `ObjectsReadWrite` | `arn:aws:s3:::sast-memory/*` | The agent reads, writes and deletes objects and their versions and reads and writes object tags; this covers row objects, blobs, the index, the Select probe, and the `kind` tag the store filters by. | + +So the agent writes objects freely but cannot change bucket settings: turning versioning off or +removing the expiry rule needs the admin. + +## The `.env` settings + +The store reads its endpoint and credentials from `.env` in the working directory or from the +environment (`minio_settings`), never from a manifest, and never prints them. + +| Variable | Value for this server | Notes | +| --- | --- | --- | +| `OUSAST_MEMORY` | `minio://sast-memory` | `minio://[/]`; unset means the local `FileStore` | +| `MINIO_ENDPOINT` | `files.because-security.com` | host[:port] without a scheme (the `minio` SDK's endpoint); required | +| `MINIO_ACCESS_KEY` | the agent account's access key | required | +| `MINIO_SECRET_KEY` | the agent account's secret key | required | +| `MINIO_SECURE` | `true` | TLS; `0`, `false`, `no` or `off` turn it off; default `true` | +| `MINIO_REGION` | optional | spares a GetBucketLocation call | + +- **Never commit `.env`.** It is gitignored (`.gitignore`: `.env`, `.env.*`, except + `.env.example`). `.env.example` lists these names with empty values. +- **`.env` never overrides a variable already exported in your shell** (`config.load_dotenv`). + A stale `export MINIO_SECRET_KEY=...` in your profile silently wins over the file; `unset` it + when a change in `.env` seems to have no effect. +- The store needs the `minio` extra (`uv sync --extra minio`); without it opening the store fails + with `OUSAST_MEMORY=minio://... needs the minio SDK: install openultrasast[minio]`. A missing + variable fails with `OUSAST_MEMORY=minio://... needs MINIO_ENDPOINT, MINIO_ACCESS_KEY, + MINIO_SECRET_KEY in .env or the environment` (naming only the missing ones). + +## What the store verifies at startup + +`MinioStore.verify_bucket()` runs once per process for each bucket and prefix. It reads the +versioning status and the lifecycle rules, writes a two-line probe object at +`_probe/select.jsonl` (under the prefix, if any), queries it with S3 Select and reads its tags. +Every missing piece is collected into one error: + +```text +memory minio://sast-memory: the bucket is not set up for the store. The store never configures its bucket (an admin sets it once, the store verifies); missing: + - +``` + +The lines, exactly as the code writes them for this bucket at the root (`` is the +server's error code and the first 200 characters of its message): + +| Check | Refusal line | +| --- | --- | +| versioning unreadable | `versioning cannot be read (): the agent needs s3:GetBucketVersioning on arn:aws:s3:::sast-memory, and versioning must be Enabled` | +| versioning off | `versioning is Off, it must be Enabled (provenance cites object versions). Admin: aws s3api put-bucket-versioning --endpoint-url "$MINIO_ENDPOINT" --bucket sast-memory --versioning-configuration Status=Enabled` (the actual status, e.g. `Suspended`, replaces `Off` when set) | +| lifecycle unreadable | `the lifecycle configuration cannot be read (): the agent needs s3:GetLifecycleConfiguration on arn:aws:s3:::sast-memory, and a rule must expire runs/` | +| a rule expires everything | `lifecycle rule(s) expire the whole store (repos/, facts/ and the index would be deleted); limit them to runs/` | +| no `runs/` rule | `no enabled lifecycle rule expires runs/ after a number of days. Admin (merged with any rules the bucket already has): aws s3api put-bucket-lifecycle-configuration --endpoint-url "$MINIO_ENDPOINT" --bucket sast-memory --lifecycle-configuration '{"Rules":[{"ID":"ousast-runs-expiry","Status":"Enabled","Filter":{"Prefix":"runs/"},"Expiration":{"Days":30}}]}'` | +| tags unreadable | `object tags cannot be read on _probe/select.jsonl (): the store filters row objects by their kind tag, so the agent needs s3:GetObjectTagging on arn:aws:s3:::sast-memory/*` | +| S3 Select missing | `S3 Select (SelectObjectContent over JSON Lines) does not answer on _probe/select.jsonl (). The store requires it and has no local fallback: use a server with S3 Select (RustFS has it; some MinIO releases removed it) and let the agent s3:PutObject and s3:GetObject` | + +The admin commands inside the messages use `--endpoint-url "$MINIO_ENDPOINT"`; with the +host-only `MINIO_ENDPOINT` above, pass the full URL (`https://files.because-security.com`) as in +the steps on this page. + +There is **no fallback**: the store does not start in a degraded mode, does not drop to a local +store, and does not fetch and filter objects itself. + +## The RustFS Select limit and the fixed-schema rule + +RustFS's S3 Select infers an object's JSON schema from its leading rows. A `where` field that is +absent from those rows is unbound, even if a later row has it: the query fails with +`EvaluatorBindingDoesNotExist` instead of matching (measured 2026-10-02 with the field first +present at row 5000). The store does not read that as "no rows", since that answer could silently +drop matches; it raises: + +```text +memory minio://sast-memory: S3 Select on failed (EvaluatorBindingDoesNotExist: ...) (a field of [] is absent from the object's leading rows, which the server reads as its schema); the store has no local fallback +``` + +The rule that follows: a field the store filters on must be present in every row of its kind +from the first row on, i.e. each row kind has a fixed set of queryable fields. Enforcing that set +per kind in the store is in progress and not on main as of this page (see +[Memory](memory.md#queryable-fields-per-kind-in-progress)). + +## Checks against the real server + +The contract tests that talk to a real server run only with `OUSAST_MEMORY_TEST_MINIO=1` (bucket +`OUSAST_MEMORY_TEST_BUCKET`, else `MINIO_BUCKET`). They verify the bucket at its root and write +only under a fresh `contract-/` prefix; they never configure the bucket. More on the host +setup: [ax on this host](ops/ax/README.md). diff --git a/docs/scanning.md b/docs/scanning.md new file mode 100644 index 0000000..fb6882d --- /dev/null +++ b/docs/scanning.md @@ -0,0 +1,125 @@ +# Scanning + +`ousast scan PATH --mode quick|standard|deep` scans a checkout; `ousast pre-push` checks only +what a push (or an explicit `--base`/`--head` pair) changes. The stage-by-stage pipeline, +evidence ladder and score are in [Architecture](architecture.md); this page shows the flows. + +## The three scan modes + +The stages a mode requests are `MODE_STAGES` in `src/openultrasast/stages.py`: `quick` runs +STATIC, `standard` adds MAP, `deep` adds REGRESS. + +```mermaid +flowchart TD + start["ousast scan PATH --mode M"] --> policy["policy_load, ruleset_load, policy_check"] + policy --> prep["static_mapping, preprocess, entry_point_mapping, rank, calibrate"] + prep --> which{"mode"} + which -- "quick, deep" --> qf["quick_findings: language-scoped pattern rules"] + which -- "standard" --> hp["hunter_pool: the same rules per ranked target"] + qf --> verify["verify, record_calibration, score"] + hp --> verify + verify --> mapq{"MAP requested?"} + mapq -- "quick: no" --> out["report.md, report.sarif, manifest.json"] + mapq -- "standard, deep" --> map["MAP: complexity map, semantic overlay, obligations, Joern model layer"] + map --> regq{"REGRESS requested?"} + regq -- "standard: no" --> out + regq -- "deep" --> reg["REGRESS: snippets in a Docker sandbox, verdicts.json"] + reg --> out +``` + +| Mode | Adds | Needs | Without it | +| --- | --- | --- | --- | +| `quick` | pattern rules, entry-point hints, ranking, verification, score | nothing | - | +| `standard` | MAP: complexity map, semantic overlay, authorization obligations, the Joern model layer; `fusion` (deterministic) | Joern; a provider key for the residual LLM question | `cpg_unavailable` degradation; the LLM parts are skipped and recorded | +| `deep` | REGRESS: promoted candidates loaded in a sandbox with no network, read-only source, non-root | a working `docker` | `sandbox_unavailable` degradation | + +Sources: `README.md` (scan modes), `src/openultrasast/cli.py` (`run_stage` calls), +[Architecture](architecture.md#the-scan-pipeline). + +## Quick rules per language + +Rules run only against files of their language. The bundled ruleset lives in +`src/openultrasast/ruleset//rules.toml`. + +| Language | Classes (CWE) | +| --- | --- | +| C / C++ | stack buffer overflow (121), format string (134), command injection (78) | +| Python | command injection (78), code injection (95), SSTI (94), deserialization (502), SQLi (89), path traversal (22), SSRF (918), weak hash (327), insecure default (489) | +| JavaScript / TS | command injection (78), code injection (95), XSS (79), SQLi (89), path traversal (22), SSRF (918), weak hash (327), deserialization (502) | +| Java + Groovy templates | command injection (78), SQLi (89), weak hash (327), deserialization (502), unescaped template XSS (79) | +| PHP | SQLi (89), command injection (78), file inclusion (98), path (22), open redirect (601), SSRF (918); code eval (95), unserialize (502) and echo XSS (79) ship as `shadow` rules | + +The detection gate (`python -m openultrasast.gate`) enforces at least 90% recall and under 10% +false positives per language on the bundled corpora in `benchmarks/manifests/`. Those corpora are +cheat-sheet fixtures: they catch regressions, they do not estimate recall on real code. **PHP is +not in the gate**; its numbers are in-sample development numbers +(`benchmarks/measurements/2026-09-30-php-quick-rules/measurement.json`). See `README.md` for the +gate's per-language output. + +## The Joern engine + +The model layer (`src/openultrasast/model/`, `src/openultrasast/cpg/`) is the core of `standard`. +It builds one code property graph per scan with Joern (`joern-parse`, then batched +`joern --script` queries from `cpg/queries/*.sc`) and sends each question to the arbiter of its +type: **taint** (source to sink with no discharging sanitizer), **dominance** (does a guard govern +the obligated operation) and **configuration** (constant abstraction over a value). Taint facts +ship for C, Java, JavaScript, PHP and Python (`src/openultrasast/ruleset/semantic/*.toml`). + +```mermaid +flowchart LR + src["Checkout"] --> parse["joern-parse: one CPG per repository"] + parse --> q["Batched joern --script queries"] + q --> arb{"Arbiter by question type"} + arb --> taint["Taint: flow with no discharging sanitizer"] + arb --> dom["Dominance: obligation with no guard"] + arb --> conf["Configuration: permissive value"] + taint --> rung["Rung: model_entailed or suspicion"] + dom --> rung + conf --> rung + rung -- "residual question only" --> judge["LLM judge: confirm or contradict, never create a flow"] +``` + +The Docker image ships Joern and `php-cli` (which the PHP frontend needs); without Joern the +layer records `cpg_unavailable` and the scan is otherwise unchanged. Engine installation and the +Joern features used are in [Engine and pre-push](ops/README.md). + +## Framework priors + +Rule and taint-fact entries that know a framework or library carry a `framework` or `library` tag +naming a row of `src/openultrasast/ruleset/frameworks.toml` (for example `wordpress`, `flask`, +`django`, `express`, `spring`). The loaders take `priors=`: `"all"` (the default and today's +behaviour), `"off"` (language-level knowledge only) or a set of ids, so the contribution of +framework knowledge can be measured and switched off. The decision engine's first slice ran with +priors off ([Decision engine](decision-engine.md#first-measured-slice-2026-10-01)). + +## Pre-push: the delta check (experimental) + +`ousast pre-push` analyses the commits a `git push` would publish, or an explicit +`--base`/`--head` pair, and compares head with base so that only new or worsened defects are +candidates. Sources: `ousast pre-push --help`, `src/openultrasast/push/`, +[Engine and pre-push](ops/README.md#explicit-local-replay). + +```mermaid +flowchart TD + input["git push refs on stdin, or --base / --head"] --> resolve["Resolve both commit identities"] + resolve --> snap["Materialise tracked bytes in private scratch storage"] + snap --> regions["Discover changed regions, up to --max-regions"] + regions --> engine["Evidence ranker and arbiter on head"] + engine --> cmp["Targeted base comparison: new or worsened only"] + cmp --> admit{"Admission policy: capability registry"} + admit -- "registry empty today" --> diag["Diagnostic candidates in the artifact"] + admit -- "an enabled capability" --> alert["Normal alert"] + diag --> art["JSON artifact outside the repository"] + alert --> art + art --> exit{"--mode"} + exit -- "advisory, the default" --> allow["Exit 0: allow"] + exit -- "blocking, explicit opt-in" --> block["May block the push"] +``` + +- `--deadline` (30 s by default) bounds preparation, both revisions and reporting together; + `--incomplete-coverage block` opts into failing when coverage is incomplete. +- The default capability registry is empty pending independent qualification, so no normal alert + is emitted today; every candidate stays diagnostic. +- A model is called only with an explicit `--model-config`. +- Hook installation (`ops/install-pre-push`) and the private cache (`--cache-dir`) are documented + in [Engine and pre-push](ops/README.md#experimental-pre-push-integration). diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..43f55dc --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,66 @@ +site_name: OpenUltraSAST +site_description: An experimental static security analyser and repository safety net, with an agentic plane and a learned decision engine in development. +repo_url: https://github.com/norandom/OpenUltraSAST +edit_uri: edit/main/docs/ +docs_dir: docs +site_dir: site +strict: true + +theme: + name: material + features: + - navigation.sections + - navigation.top + - content.code.copy + - search.highlight + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-4 + name: Dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-7 + name: Light mode + +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + anchors: warn + +markdown_extensions: + - admonition + - attr_list + - tables + - toc: + permalink: true + - pymdownx.details + - pymdownx.snippets: + base_path: ["."] + check_paths: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + +nav: + - Overview: index.md + - How it works: + - Scanning: scanning.md + - Architecture: architecture.md + - The plane on ax: plane.md + - Memory: memory.md + - Memory and detection: memory-and-detection.md + - Decision engine: decision-engine.md + - Evaluation: evaluation.md + - Using it: + - Examples: examples.md + - Threat model: threat-model.md + - Operations (maintainers): + - Engine and pre-push: ops/README.md + - ax on this host: ops/ax/README.md + - RustFS setup for the memory store: rustfs.md diff --git a/pyproject.toml b/pyproject.toml index 102a8a2..e22909c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,11 @@ requires-python = ">=3.11" dependencies = ["pyyaml>=6.0"] [project.optional-dependencies] +# The documentation site (mkdocs.yml, docs/). Mermaid renders client-side through Material's superfences fence. +docs = [ + "mkdocs>=1.6,<2", + "mkdocs-material>=9.5,<10", +] # The plane's memory store on MinIO (OUSAST_MEMORY=minio://, plane/memory.py). Imported lazily; the # core install and the file store need nothing beyond PyYAML. minio = [ diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py new file mode 100644 index 0000000..49f033b --- /dev/null +++ b/tests/test_docs_site.py @@ -0,0 +1,158 @@ +"""The documentation site builds strictly, its Mermaid diagrams are well formed, and the RustFS page is complete. + +The strict build runs only where ``mkdocs`` is importable (the ``docs`` extra); the other checks read the Markdown +and always run. The Mermaid check is syntactic (fences, a known diagram type, balanced brackets and quotes, the node +budget): the site renders diagrams in the browser, so nothing here can run the real parser. +""" + +from __future__ import annotations + +import importlib.util +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" +PAGES = sorted(DOCS.rglob("*.md")) +DIAGRAM_TYPES = ("flowchart", "graph", "sequenceDiagram", "stateDiagram-v2", "stateDiagram", "classDiagram", "erDiagram") +MAX_NODES = 15 +FENCE = re.compile(r"^(\s*)(`{3,}|~{3,})(.*)$") + +BUCKET_ACTIONS = ( + "s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetLifecycleConfiguration", +) # fmt: skip +OBJECT_ACTIONS = ( + "s3:GetObject", "s3:GetObjectVersion", "s3:PutObject", "s3:DeleteObject", "s3:DeleteObjectVersion", + "s3:GetObjectTagging", "s3:PutObjectTagging", "s3:GetObjectVersionTagging", +) # fmt: skip +SECRET_SHAPES = ( + re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), # AWS-style access key id + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), + re.compile(r"\b(?:sk|pk)-[A-Za-z0-9_-]{16,}"), # provider API keys + re.compile(r"(?:SECRET|ACCESS)_KEY\s*=\s*[^\s<`.]"), # an assignment with a value + re.compile(r"(? list[str]: + """The bodies of the ```mermaid fences. Every fence must close with the same marker (balanced fences).""" + blocks: list[str] = [] + opener: str | None = None # the open fence's marker, None outside a fence + language, body = "", [] + for line in text.splitlines(): + match = FENCE.match(line) + if opener is None: + if match: + opener, language, body = match.group(2), match.group(3).strip(), [] + continue + if match and match.group(2).startswith(opener) and not match.group(3).strip(): + if language == "mermaid": + blocks.append("\n".join(body)) + opener = None + continue + body.append(line) + assert opener is None, f"{where}: a {opener} fence is never closed" + return blocks + + +def _balanced(line: str) -> bool: + outside = re.sub(r'"[^"]*"', '""', line) + if outside.count('"') % 2: + return False + stack: list[str] = [] + for char in outside: + if char in "[({": + stack.append(char) + elif char in "])}" and (not stack or "[({"["])}".index(char)] != stack.pop()): + return False + return not stack + + +def _nodes(block: str) -> set[str]: + """Node ids of a flowchart: the leading identifier of every edge endpoint, labels and shapes removed.""" + nodes: set[str] = set() + for raw in block.splitlines()[1:]: + line = re.sub(r'"[^"]*"', "", raw).strip() + if not line or line.startswith(("%%", "subgraph", "end", "classDef", "class ", "style ", "direction")): + continue + line = re.sub(r"\[[^\]]*\]|\([^)]*\)|\{[^}]*\}", "", line) + for part in re.split(r"-\.->|-->|==>|---|--|&", line): + match = re.match(r"\s*([A-Za-z_]\w*)", part) + if match: + nodes.add(match.group(1)) + return nodes + + +def test_every_page_has_well_formed_mermaid() -> None: + assert PAGES, "docs/ has no pages" + total = 0 + for page in PAGES: + for block in mermaid_blocks(page.read_text(encoding="utf-8"), page.name): + total += 1 + lines = [ln for ln in block.splitlines() if ln.strip() and not ln.strip().startswith("%%")] + assert lines, f"{page.name}: empty mermaid block" + kind = lines[0].split()[0] + assert kind in DIAGRAM_TYPES, f"{page.name}: unknown diagram type {kind!r}" + for line in lines[1:]: + assert _balanced(line), f"{page.name}: unbalanced brackets or quotes in {line.strip()!r}" + if kind == "sequenceDiagram": + assert ";" not in line, f"{page.name}: ';' ends a sequence-diagram statement: {line.strip()!r}" + if kind in ("flowchart", "graph"): + found = _nodes("\n".join(lines)) + assert len(found) <= MAX_NODES, f"{page.name}: {len(found)} nodes > {MAX_NODES}: split the diagram" + assert total >= 10, f"only {total} mermaid diagrams in docs/" + + +def test_mermaid_fence_is_configured() -> None: + config = (ROOT / "mkdocs.yml").read_text(encoding="utf-8") + assert "pymdownx.superfences" in config and "name: mermaid" in config and "fence_code_format" in config + + +def test_rustfs_page_lists_the_complete_policy_and_no_secret() -> None: + text = (DOCS / "rustfs.md").read_text(encoding="utf-8") + for action in BUCKET_ACTIONS + OBJECT_ACTIONS: + assert f'"{action}"' in text, f"rustfs.md: the policy lacks {action}" + for needle in ( + '"Sid": "BucketSettingsReadOnly"', '"Sid": "ObjectsReadWrite"', '"arn:aws:s3:::sast-memory"', '"arn:aws:s3:::sast-memory/*"', + "put-bucket-versioning", "put-bucket-lifecycle-configuration", "--endpoint-url https://files.because-security.com", + "--bucket sast-memory", '"Prefix":"runs/"', '"Days":30', "OUSAST_MEMORY", "minio://sast-memory", "MINIO_ENDPOINT", + "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY", "MINIO_SECURE", "MINIO_REGION", "EvaluatorBindingDoesNotExist", + ): # fmt: skip + assert needle in text, f"rustfs.md lacks {needle!r}" + for shape in SECRET_SHAPES: + hit = shape.search(text) + assert hit is None, f"rustfs.md has a secret-looking string near {hit.group(0)[:6]!r}..." + + +def test_rustfs_page_quotes_the_refusals_verify_bucket_writes() -> None: + """Each refusal line the page quotes still appears, as a template fragment, in the store's code.""" + code = (ROOT / "src" / "openultrasast" / "plane" / "memory.py").read_text(encoding="utf-8") + for fragment in ( + "the bucket is not set up for the store. The store never configures its ", + "it must be Enabled (provenance cites object versions)", + "expire the whole store (repos/, facts/ and the index would be ", + "no enabled lifecycle rule expires", + "the store filters row objects by ", + "The store requires it and has no local fallback", + "is absent from the object's leading rows, which the server reads as its schema", + ): + assert fragment in code, f"memory.py no longer writes {fragment!r}: update docs/rustfs.md" + + +@pytest.mark.skipif(importlib.util.find_spec("mkdocs") is None, reason="mkdocs is not installed (the docs extra)") +def test_site_builds_strictly(tmp_path: Path) -> None: + proc = subprocess.run( + [sys.executable, "-m", "mkdocs", "build", "--strict", "-f", str(ROOT / "mkdocs.yml"), "-d", str(tmp_path / "site")], + cwd=ROOT, capture_output=True, text=True, timeout=300, + ) # fmt: skip + assert proc.returncode == 0, f"mkdocs build --strict exited {proc.returncode}:\n{proc.stderr[-3000:]}" + pages = list((tmp_path / "site").rglob("*.html")) + assert len(pages) >= 12, f"the build wrote only {len(pages)} pages" # proves the build read the docs + ax = (tmp_path / "site" / "ops" / "ax" / "index.html").read_text(encoding="utf-8") + assert "Bring-up" in ax, "the ax operations page did not include ops/ax/README.md" + leaked = [p.name for p in pages if ".kiro" in p.read_text(encoding="utf-8") or "kiro-" in p.read_text(encoding="utf-8")] + assert not leaked, f"maintainer Kiro tooling reached the site: {leaked}" diff --git a/uv.lock b/uv.lock index 5c310dd..02208fe 100644 --- a/uv.lock +++ b/uv.lock @@ -132,6 +132,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "backoff" version = "2.2.1" @@ -141,6 +150,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "backrefs" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, +] + [[package]] name = "beartype" version = "0.22.9" @@ -340,6 +362,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -403,6 +434,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.75.0" @@ -508,6 +551,18 @@ 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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -581,6 +636,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "markdown" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/4f/700155c8c20d9e655dd0732b5fc3c7614f291b9148da271d7388e50bf774/markdown-3.11.tar.gz", hash = "sha256:180224db6aed87ba9ce1f2781ebcd5826253de8ff637112090e24b84502bbf9f", size = 485623, upload-time = "2026-09-25T13:46:23.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl", hash = "sha256:cd6c89e7eb308c8b332ed673215a52d208a43f8bacc030b1419376129408719e", size = 111296, upload-time = "2026-09-25T13:46:22.163Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -593,6 +657,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -602,6 +740,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + [[package]] name = "minio" version = "7.2.20" @@ -618,6 +765,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, ] +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -923,6 +1139,10 @@ dependencies = [ ] [package.optional-dependencies] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, +] minio = [ { name = "minio" }, ] @@ -948,6 +1168,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "minio", marker = "extra == 'minio'", specifier = ">=7.2,<8" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6,<2" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5,<10" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "tree-sitter", marker = "extra == 'semantic'", specifier = ">=0.23,<0.27" }, { name = "tree-sitter-c", marker = "extra == 'semantic'", specifier = ">=0.23" }, @@ -957,7 +1179,7 @@ requires-dist = [ { name = "tree-sitter-python", marker = "extra == 'semantic'", specifier = ">=0.23" }, { name = "tree-sitter-typescript", marker = "extra == 'semantic'", specifier = ">=0.23" }, ] -provides-extras = ["minio", "semantic"] +provides-extras = ["docs", "minio", "semantic"] [package.metadata.requires-dev] dev = [ @@ -977,6 +1199,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -1194,6 +1425,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/94/858e0163cb4d83d6d8234018a01792c749a56daee41794cac7d6c46661e8/pymdown_extensions-12.1.tar.gz", hash = "sha256:fdb8f47f5d7fd069d10ef2a7d8908e613d7865ff0419d544a0ffd3984e884f11", size = 868245, upload-time = "2026-09-23T00:08:42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -1210,6 +1454,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.4.2" @@ -1278,6 +1534,18 @@ wheels = [ { 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 = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1331,6 +1599,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "tree-sitter" version = "0.26.0" @@ -1497,6 +1774,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wrapt" version = "2.2.1"