diff --git a/.docker/Dockerfile.fleet-host b/.docker/Dockerfile.fleet-host index d29567461..b53921938 100644 --- a/.docker/Dockerfile.fleet-host +++ b/.docker/Dockerfile.fleet-host @@ -1,5 +1,5 @@ -# Build stage follows global.json (SDK 10). The commercial Fleet.Host project still targets net8.0, -# so the runtime stage stays on the 8.0 ASP.NET Core image (commercial/ is out of scope for the net10 move). +# Build stage follows global.json (SDK 10). The commercial Fleet.Host project targets net10.0, so the +# runtime stage is the 10.0 ASP.NET Core image. FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src diff --git a/.github/workflows/build-gate.yml b/.github/workflows/build-gate.yml index 913b32b8e..00346696d 100644 --- a/.github/workflows/build-gate.yml +++ b/.github/workflows/build-gate.yml @@ -21,7 +21,7 @@ concurrency: jobs: build-core: name: build-core - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/cert-gate.yml b/.github/workflows/cert-gate.yml index d4b7eea53..b51a6bf64 100644 --- a/.github/workflows/cert-gate.yml +++ b/.github/workflows/cert-gate.yml @@ -17,7 +17,7 @@ concurrency: jobs: cert-gate: name: cert-gate - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - name: Checkout diff --git a/.github/workflows/docs-link-check.yml b/.github/workflows/docs-link-check.yml index 4ee92e164..49fb93702 100644 --- a/.github/workflows/docs-link-check.yml +++ b/.github/workflows/docs-link-check.yml @@ -26,7 +26,7 @@ concurrency: jobs: lychee: name: lychee (README + docs) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/full-platform-readiness-gate.yml b/.github/workflows/full-platform-readiness-gate.yml index 28671a1c0..57e32ba5f 100644 --- a/.github/workflows/full-platform-readiness-gate.yml +++ b/.github/workflows/full-platform-readiness-gate.yml @@ -7,7 +7,7 @@ # 3. Dry-run — `ashlar doctor --fix --dry-run --json` + `ashlar bootstrap apply --dry-run --json` + smoke tests # # Native runners: ubuntu, macOS, Windows -# Container runners: Ubuntu, Alpine, Debian (Docker on ubuntu-latest) +# Container runners: Ubuntu, Alpine, Debian (Docker on ubuntu-24.04) # # Trigger policy (see .github/workflows/README.md, docs/CiGateInventory.md): # - pull_request: UNFILTERED on purpose. The `changes` job decides in-workflow whether any @@ -162,7 +162,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── changes: name: "Changes — core paths touched?" - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read outputs: @@ -344,13 +344,13 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest + - os: ubuntu-24.04 label: Linux setup_cmd: bash scripts/setup/setup.sh - - os: macos-latest + - os: macos-26 label: macOS setup_cmd: bash scripts/setup/setup.sh - - os: windows-latest + - os: windows-2025-vs2026 label: Windows setup_cmd: pwsh scripts/setup/setup.ps1 -Mode @@ -520,7 +520,7 @@ jobs: name: "${{ matrix.label }} (container) — setup · discover · dry-run" needs: changes if: needs.changes.outputs.run_heavy == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -620,7 +620,7 @@ jobs: name: "Docker CLI image — build · smoke · discover" needs: changes if: needs.changes.outputs.run_heavy == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v7 @@ -653,7 +653,7 @@ jobs: # Production images are never built on PRs (kept from before the PR trigger existed); # on push/schedule/dispatch they additionally follow the shared run_heavy decision. if: github.event_name != 'pull_request' && github.event_name != 'merge_group' && needs.changes.outputs.run_heavy == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # Builds take ~1 minute each (layer cache); the smoke below is bounded at 2 minutes. # The previous 90-minute budget only ever measured how long a hung smoke could hang. timeout-minutes: 20 @@ -707,7 +707,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── readiness-summary: name: "Readiness summary" - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [changes, native-platform, container-platform, docker-cli-image, docker-all-images] if: always() # `verdict` distinguishes "every platform passed" from "no platform ran". Both exit 0 - this is a diff --git a/.github/workflows/shell-lint.yml b/.github/workflows/shell-lint.yml index 84e95fdb3..57c45365b 100644 --- a/.github/workflows/shell-lint.yml +++ b/.github/workflows/shell-lint.yml @@ -27,7 +27,7 @@ on: jobs: shell-lint: name: shell-lint - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - uses: actions/checkout@v7 @@ -51,7 +51,7 @@ jobs: shell: bash run: | set -euo pipefail - # ubuntu-latest ships shellcheck; install only if the image ever drops it. + # The ubuntu-24.04 image ships shellcheck; install only if the image ever drops it. if ! command -v shellcheck >/dev/null 2>&1; then sudo apt-get update -qq sudo apt-get install -y -qq shellcheck diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4524845..77fce6684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,19 +17,9 @@ the version on nuget.org, which is why it trails `VERSION` between releases rath ## [Unreleased] -### Changed - -- **An unfiltered `ashlar validate` / `ashlar ci verify` fails a discovered test project that executes zero tests** - including an all-skipped, all-Stress or all-DockerOptional project. The failure is an evidence error, so no test count is invented; the same result reaches `POST /validate` and the director's RunValidation option. A caller-supplied filter that selects nothing still passes. A test project made only of `[NotOnCiFact]` / `[OptInFact]` tests needs one fact that runs without the dependency. -- **cert-gate refuses a newly skipped certification test.** The TRX `total` counts skips, so the existing floor let one through; `scripts/cert-gate-skip-guard.sh` pins `total - executed` to `scripts/cert-gate-skipped.baseline`, which fails in both directions. `cert-gate-summary.sh` no longer exits 0 on a missing TRX. -- **A pinned `dotnet test --filter` that selects nothing fails its lane.** `scripts/ci/zero-test-guard.sh` follows 91 invocations; `scripts/ci/verify-zero-test-guard-wiring.py` (required `shell-lint`) refuses one without a guard. security-gate Tier B, application-gate Tier C and compat-gate Tier A had each been passing on zero tests. -- **`Readiness summary` says whether any platform ran** - `verified`, `partial`, `not-verified` or `failed` - instead of printing ALL PLATFORMS PASSED when every lane was skipped, and publishes it as a `Readiness verdict` annotation. `scripts/release/readiness-verdict-for-sha.sh ` reads it and exits 0 only for a VERIFIED read from GitHub; the release runbook requires it before tagging (step 9). -- **The readiness trigger covers the whole build closure of the suites it runs.** The routing gap left open in 0.2.0 (see the 0.2.0 notes) is closed: 41 directories added across #684 and this change, and `ReadinessClosureConventionTests` (cert-gate) fails naming any closure directory either path list misses. Files read through MSBuild property functions are not traced. - -### Fixed +Nothing yet. -- Records that named the wrong runner or gate: `ci/test-ownership.tsv`, the SPEC-006 conformance paragraph, the May readiness sign-off sheets (each PASS now annotated with its evidence), two docs that placed `MeshTaskExecutionServiceTests` wrongly, and the release checklist, which pointed nuget.org Trusted Publishing at `release.yml` instead of `reusable-release-nuget.yml`. - -## [0.2.0] - 2026-09-29 +## [0.2.0] - 2026-10-01 ### Breaking @@ -346,7 +336,15 @@ The remedy is **`ashlar gates sign-activate --repair`**, which re-mints the inve - **Migration: this changes the bytes of any record carrying a double that is not exactly representable, and of any record carrying negative zero — and no record in this tree carries either.** Verified rather than assumed. Exactly three sites sign a certification record, and all three reach the signer with `escapeRate` at zero or null: `CertificationGate` and `CompositionCertificationGate` sign only the ADMITTED record, which is reachable only past a rejection on `EscapeRate > 0`, and `LoadRefusalRecord` never assigns it. `durationSeconds` cannot carry a value at all — no production code populates `Attempts`; every assignment of an attempt list is in tests. The three signed records committed to the tree (`samples/certified-brick-reuse/Ashlar.Certified.DamageResolver/certification-record.json`, `spikes/portability/generated/certification-record.json`, `spikes/portability/generated/error-summary-extractor.json`) each carry `"escapeRate": 0` and no attempts, so all three keep their exact bytes and their existing signatures. FAIL records from the gate *do* carry an awkward escape rate (1/3 for one survivor in three) — they are never signed, and the trust verifier reports `record-unsigned` before it ever builds a payload. The invariant therefore holds by the gate's control flow, not by any type constraint, which is exactly why it is pinned in the corpus now rather than trusted. - **The corpora grow; nothing already pinned moves.** G17 of 0, 0.25 and 0.5 is `0`, `0.25` and `0.5` on all three targets, and those are the only double values any corpus held, so all five `canonical-payloads.golden.json` cases, all four composition cases and all three transition cases keep their exact payload strings, byte lengths and SHA-256s — re-measured, not asserted. Four brick cases and two composition cases are **added**, carrying the values that were not exactly representable: `v1-legacy-awkward-double` (0.1 on the lane with no attempts array), `v2-awkward-doubles` (1/3 as `escapeRate`, 0.007 and 1e-7 as `durationSeconds`), `v2-zero-signs` (`-0` and `+0` in both fields, all emitting `0`), `v2-double-extremes` (`double.Epsilon`, 1e23 — where a shortest form and a 17-digit form are different text — and `double.MaxValue`), plus `composition-awkward-double` and `composition-zero-signs`. `CanonicalDoubleRepresentationTests` pins the reasons a corpus entry cannot state: the form for each value in all three double-bearing fields, the round trip back to the identical bit pattern, the zero collapse, the non-finite refusal on both lanes and through the verifier, and independence from the ambient culture's decimal separator. - **Measured on every target the change can reach, and one it cannot.** All nine brick payloads are byte-identical and SHA-256-identical on net8.0, net10.0 and the shipped `netstandard2.0` asset executed under Mono 6.12 (`scripts/ns20-canonical-bytes-probe.sh`), and on net10.0 published `PublishTrimmed`/`partial`, `PublishTrimmed`/`full`, the same with reflection re-enabled, and `PublishAot`. **The gap, stated rather than implied:** the ns2.0 leg is the one that diverged, and at the time of this change no workflow ran Mono — `scripts/ns20-canonical-bytes-probe.sh` was a repeatable local measurement, not CI coverage. **Closed later in this same cycle** by the `mono-netstandard20-consumer` lane described above, which executes that probe under `mono:latest`. After this change the awkward-double cases are checked in CI by xunit on net8.0/net10.0 and by the new trim/AOT lane on net10.0, all of which already agreed; pinning them on the ns2.0 asset stays a local run until a workflow executes it. The parity claim is measured on Mono 6.12.0.182 specifically, which is what `mono:latest` ships; the same asset can be loaded by a .NET Framework host this tree cannot execute. - +- **An unfiltered `ashlar validate` / `ashlar ci verify` fails a discovered test project that executes zero tests** - including an all-skipped, all-Stress or all-DockerOptional project. The failure is an evidence error, so no test count is invented; the same result reaches `POST /validate` and the director's RunValidation option. A caller-supplied filter that selects nothing still passes. A test project made only of `[NotOnCiFact]` / `[OptInFact]` tests needs one fact that runs without the dependency. +- **cert-gate refuses a newly skipped certification test.** The TRX `total` counts skips, so the existing floor let one through; `scripts/cert-gate-skip-guard.sh` pins `total - executed` to `scripts/cert-gate-skipped.baseline`, which fails in both directions. `cert-gate-summary.sh` no longer exits 0 on a missing TRX. +- **A pinned `dotnet test --filter` that selects nothing fails its lane.** `scripts/ci/zero-test-guard.sh` follows 91 invocations; `scripts/ci/verify-zero-test-guard-wiring.py` (required `shell-lint`) refuses one without a guard. security-gate Tier B, application-gate Tier C and compat-gate Tier A had each been passing on zero tests. +- **`Readiness summary` says whether any platform ran** - `verified`, `partial`, `not-verified` or `failed` - instead of printing ALL PLATFORMS PASSED when every lane was skipped, and publishes it as a `Readiness verdict` annotation. `scripts/release/readiness-verdict-for-sha.sh ` reads it and exits 0 only for a VERIFIED read from GitHub; the release runbook requires it before tagging (step 9). +- **The readiness trigger covers the whole build closure of the suites it runs.** The routing gap left open in 0.2.0 (see the 0.2.0 notes) is closed: 41 directories added across #684 and this change, and `ReadinessClosureConventionTests` (cert-gate) fails naming any closure directory either path list misses. Files read through MSBuild property functions are not traced. +- **Required checks run on pinned runner images.** cert-gate, build-core, shell-lint, lychee and every `Readiness summary` lane now run on `ubuntu-24.04`, `macos-26` or `windows-2025-vs2026`, the images their `-latest` labels resolved to on 2026-09-30, so GitHub's move of `ubuntu-latest` to Ubuntu 26.04 from 2026-10-19 (shellcheck 0.11, Python 3.14) cannot change them without a pull request. `scripts/ci/verify-required-runner-pins.py` (required `shell-lint`) refuses a floating label in any job of those five workflows unless a `# runner-pin: allow ` comment sits on the label's line. +- **cert-gate refuses a suppression that switches off public-API tracking or hides a deprecation.** `DiagnosticSuppressionConventionTests` fails any suppression of RS0016, RS0017, RS0026 or RS0027, and a CS0618 or ASHLAREXP001 disable that does not name the symbol it is for with a reason. A project-wide disable of either (`NoWarn`, `WarningsNotAsErrors`, an analyzer-config severity) needs a reviewed entry; eight existing pragmas are grandfathered by exact text. +- **`background-agent rag index` requires `--sensitivity`.** Without it, or with an unknown level, it exits 1 and indexes nothing; it used to store every file as Public. Re-indexing a document at a lower label is refused (files earlier in that run are already indexed): remove it, or `rag clear`, first. **`rag search` without `--max-sensitivity` searches at Public**; it searched at TopSecret and returned the whole corpus. It reports the clearance it applied (a `Clearance:` line, a `clearance` field in JSON) and refuses an unknown level. +- **Library behaviour.** `new CloudSanitizationProxy()` with no content filter blocks every non-air-gapped prompt; it passed every prompt through. `TrustTierOrder.Rank` is `[Obsolete]`: use `RecordRank` for a label and `CallerRank` for a clearance (a consumer building with `TreatWarningsAsErrors` breaks). `VectorDataRagService.IndexAsync` stores an omitted tier as unlabelled rather than `"Public"`, `ChunkRecord.TrustTier` defaults to `""`, and `DataSensitivityMarker` reports unmarked data as the most restrictive level. ### Fixed @@ -384,7 +382,7 @@ The remedy is **`ashlar gates sign-activate --repair`**, which re-mints the inve - **The macOS host kill on `FileSystemEventSourceTests.SubscribeAsync_FileCreated_EmitsEvent` is a hang, not a crash, it reproduced twice, and it is NOT fixed here.** Both sightings carry the Blame collector’s 2-minute inactivity line, which an unhandled exception on a watcher callback thread cannot produce at all — that line means Blame killed the host for inactivity. **Its position is not the evidence and an earlier draft of this entry was wrong to say so:** the inactivity and crash lines come from streams GitHub interleaves, and in job 103182128289 the crash line is stamped 07:38:14.7839330Z against the inactivity line’s 07:38:15.1106820Z — crash printed first, in a run that was unambiguously a hang. Read the line’s presence, plus the `Sequence_*.xml`, not the order. Of the three mechanisms #601 considered, the disposal race is closed in the source and was never reachable from this test, and `CompositeEventSource` is not in its call graph; the one consistent with the logs is FSEvents/kqueue non-delivery, whose most likely trigger (#601’s replacement of a fixed `Task.Delay` with a polled re-touch loop) is gone while a permanently blind watcher is not — the source subscribes `FileSystemWatcher.Error`, but this test passes `logger: null`. What changed here is the amplifier, above: the next occurrence is a named failing test rather than a lost run. What would settle it is a macOS-only lane running the class ~200 times with the blame window set BELOW the test’s own deadline, at `c1c5bed7` and again with the pre-#601 test body — not done, because every measurement available here is Linux-in-container and “measured on one OS” means “not yet measured”. Waiting for green runs cannot answer it either: against the observed 2/86 macOS rate, three consecutive passes have probability 0.93 whether or not anything was fixed. The test now also disposes the source it creates, so the fixture’s directory delete no longer fires events at a live watcher. - **`docs/HowGatesGoQuiet.md` gains section 11: the failure is in attempt 1, and every default view shows attempt 2.** A re-run adds an attempt rather than replacing the run, and the GitHub endpoints a sweep reaches for return only the latest — so a red job someone re-ran is invisible and the run itself reports `conclusion: success`. That is how the refutation above came to be written against a passing re-run. **The section does not explain the second sighting, and an earlier draft claimed it did:** run 34374416444 is `run_attempt: 1`, `conclusion: failure`, and its failing macOS job is listed by `gh run view` and `/runs//jobs` like any other, so enumerating attempts would not have found it. Two blind spots were in play; why a sweep over “the last 60 gate runs” missed a plainly failed run is still unexplained, and the section now says so rather than letting the attempts remedy read as complete. - **The analyzer reference-set convention now scans the whole project and every Roslyn reference factory, and pins its own scan.** `No_other_file_in_this_project_builds_its_own_reference_set` used `SearchOption.TopDirectoryOnly` and two literal needles, so a copy of the forbidden ambient set one folder down passed, and so did the same hand-rolled set built through `AssemblyMetadata` rather than `MetadataReference` — both reproduced in the devtest container against the shipped version, both green. The walk is recursive (minus `bin`/`obj`, which are output) and the needle list covers all six `CreateFrom*` factories. A new fact, `The_scan_finds_the_one_file_that_is_allowed_to_build_a_reference_set`, is the positive control the convention lacked: with the two exemptions lifted the scan must match `AnalyzerReferenceSet.cs` and nothing else, and the raw walk must reach a subdirectory — so a needle that stops matching, or a walk that stops descending, fails here instead of leaving the convention passing because it can no longer recognise what it forbids. -- **`Ashlar.Analyzers.Tests` runs in no gate, and that is now said where it matters rather than only in the registry.** `ci/test-ownership.tsv` has recorded the project as UNOWNED since 2026-08-28; what was not recorded is the measured consequence — the Test.Sdk 18.9.0 CS1069 regression that reddened all seven `SelfRecursiveRegistrationAnalyzerTests` was invisible to every required and advisory check on every pull request, and so are the guards added for it. The registry note and the test's own summary now say so. No workflow is added here: a lane this repository cannot execute or observe from the change that writes it is section 1 of `docs/HowGatesGoQuiet.md`, not a fix. **Correction, 2026-09-13:** the premise of this entry is false. Native readiness discovers and runs `Ashlar.Analyzers.Tests` through `ci verify` -> `validate`; readiness run 34687917934 — the #605 run that landed these very guards — executed the project and all seven `SelfRecursiveRegistrationAnalyzerTests`, as does run 34737292726 on current master. Test.Sdk 18.9.0 never sat on master before #605 landed it together with the fix, so no master check could have observed the regression it describes. `ci/test-ownership.tsv` rows 47-49 are corrected accordingly. The conclusion to add no workflow still holds, for a different reason: what is missing is not a lane but routing — no analyzer or ingress glob appears in either readiness path list, so a PR touching only those projects still runs none of their tests. That routing gap is deliberately left open here. +- **`Ashlar.Analyzers.Tests` runs in no gate, and that is now said where it matters rather than only in the registry.** `ci/test-ownership.tsv` has recorded the project as UNOWNED since 2026-08-28; what was not recorded is the measured consequence — the Test.Sdk 18.9.0 CS1069 regression that reddened all seven `SelfRecursiveRegistrationAnalyzerTests` was invisible to every required and advisory check on every pull request, and so are the guards added for it. The registry note and the test's own summary now say so. No workflow is added here: a lane this repository cannot execute or observe from the change that writes it is section 1 of `docs/HowGatesGoQuiet.md`, not a fix. **Correction, 2026-09-13:** the premise of this entry is false. Native readiness discovers and runs `Ashlar.Analyzers.Tests` through `ci verify` -> `validate`; readiness run 34687917934 — the #605 run that landed these very guards — executed the project and all seven `SelfRecursiveRegistrationAnalyzerTests`, as does run 34737292726 on current master. Test.Sdk 18.9.0 never sat on master before #605 landed it together with the fix, so no master check could have observed the regression it describes. `ci/test-ownership.tsv` rows 47-49 are corrected accordingly. The conclusion to add no workflow still holds, for a different reason: what is missing is not a lane but routing — no analyzer or ingress glob appears in either readiness path list, so a PR touching only those projects still runs none of their tests. That routing gap was closed before this release shipped: see the readiness-closure entry under Changed. - **`docs/HowGatesGoQuiet.md` gains section 12: the convention test's lens is narrower than the rule it claims to enforce.** The comfortable failure — an inventory with both of section 7's facts, honestly maintained, red when broken, and covering a fraction of its own name, because the fix defined the check's scope and the check could therefore only confirm the fix. Three instances shipped in one change and are recorded with their measurements. It also records three lanes found in that shape and left open: `scripts/security-gate-tier-b.sh`, `scripts/application-gate-tier-c.sh` and `scripts/compat-gate-tier-a.sh` each run a `--filter` that selects zero tests in the project they run, so each passes having executed nothing — the first two because they run `-f net8.0` over classes the csproj compiles out on net8.0, the third because it names a class that exists in no project. - **`kernel-coverage` is green on master again, and the rule that let it go red now has teeth.** #594 added `src/Ashlar.Core.Application/Persistence/LiteDbConnectionString.cs` — 120 lines — and put its twelve tests in `Ashlar.Tests.Infrastructure`. `kernel-coverage` measures `Ashlar.Core.Application` against a line floor using **only** the `Ashlar.Tests.Application` run, so a fully tested type counted as uncovered and the ratio fell from above the 67% floor to **66.41%**. The gate is not a required context, so nothing blocked the merge and the first red run was on master. `docs/architecture/TestingStrategyTracking-v1.md` already assigned `src/Ashlar.Core.Application/**` to `Ashlar.Tests.Application`; the tests are moved there, which is the whole fix — measured **66.41% → 67.27%**. Three further cases cover the two paths through the option scan nothing else reached (a key that parses but is not `Connection`, and a segment with no `=` for the scan to skip), taking it to **67.33%** with 348 tests; that is worth 0.06 points, so it is coverage of a real gap rather than the fix. `scripts/ci/pr-testing-strategy-gate.sh` now fails a PR that ADDS a file under `src/Ashlar.Core.Application/` with no change under `src/Ashlar.Tests.Application/`, escapable with `[skip-app-coverage]` plus rationale in the PR body, in the same shape as the existing `[skip-prod-style]` token. Added files only: editing an existing file rarely moves the ratio, adding one always can. **The margin is thin by design and by history** — the floor is a ratchet set just under the measured value, so 67.33% is 0.33 above it and the next uncovered addition re-breaks the gate. That is what the new rule is for. - **`CompositeEventSource` never joined its children, so "the observation pipeline stopped" was never true.** `SubscribeAsync` built one pump task per child source and then discarded the join — `_ = Task.WhenAll(tasks).ContinueWith(...)` — with no `try`/`finally` around the consume loop. On every exit path (the consumer breaking out, the consumer throwing, or the token tripping) the iterator returned immediately while the children were still live. `ObservationPipelineService.ExecuteAsync` catches `OperationCanceledException` and returns, so `BackgroundService.StopAsync` completed and the host went on to tear down its DI container while `FileSystemWatcher` callback threads were still enqueueing into a source nobody was reading; `ObserveCommand` and `ImproveCommand` let `compositeSource` fall out of scope with watchers still armed on the repository root. The children are now cancelled through a linked token and awaited in a `finally`, so `SubscribeAsync` returning means every child has run its teardown. Cancelling before awaiting is not optional: `ProcessEventSource` awaits its own poll task in a `finally` and `FileSystemEventSource` waits on a channel read, so joining without cancelling would hang instead. The blanket `catch (Exception) {}` that made a dead child indistinguishable from a quiet one now logs. **Two consumer-side improvements are deliberately NOT in this change**: the `ashlar observe` and `ashlar improve` call sites should pass an `ILogger` and wrap their `FileSystemEventSource` in `using`, but both files are under `application/`, which `layer-boundary` refuses on a kernel base (`master` is runtime/kernel-first, and neither the test-only nor the coordinated-integration exemption applies to a product change). The logger parameter is optional, so both call sites compile and behave exactly as before; they need a follow-up PR on an `application/*` base. @@ -444,6 +442,8 @@ The remedy is **`ashlar gates sign-activate --repair`**, which re-mints the inve - **Two CI lanes that ran nothing now run on every pull request.** A previous change repaired two test filters that matched zero tests — but the *steps* invoking them were `if: github.event_name == 'workflow_dispatch' && …`, and `pull_request` is the only automatic trigger either workflow has, so the vacuous pass simply moved from the filter to the step condition. Application Tier B (the CLI suite, 207 tests, ~20s) and Security Tier C (the package-admission and untrusted-output suites, 61 tests, ~4s) now use the `github.event_name != 'workflow_dispatch' || …` form their sibling tiers already used; every step's condition was evaluated against a `pull_request` context to confirm the selection, and the dispatch tier choices still resolve as before. Security Gate's `paths:` were also widened to the CLI directories Tier C actually asserts on — a correct step condition never fires if no path change starts the workflow. **`TrustCommandTests` now has an automatic runner** as well: it is a `UnitTestBase` suite reachable only through the `UnitTestBridgeTests` theory, one row of which hangs, so a comment declared it un-runnable — but a single row can be selected by narrowing on `DisplayName`, exactly as the composition/mesh gate already does, and it terminates in 453 ms. - **`ashlar pkg pull --from` no longer mangles a URL into a path and then reports the operator's own store as missing.** `--from` was a `DirectoryInfo`, so System.CommandLine coerced whatever was typed — `file:///srv/mesh`, `http://peer:8080/store`, `ftp://…` — into a directory name that then "does not exist", which reads as the store having vanished rather than as the wrong kind of argument. It is a string now, resolved before use: a **`file://` URL is accepted** and resolves to the directory it names (the shape a sync client or a file manager's "copy location" hands an operator); `http`/`https` is refused with a pointer to `ASHLAR_MESH_PEERS`, which is where HTTP pull actually lives; any other scheme is refused with the scheme named back; and an empty `--from` is a refusal instead of the unhandled exception `new DirectoryInfo("")` threw. A directory whose *name* merely looks like URL syntax (`store%20v1#2`) is still passed through untouched — the scheme test reads the raw token, because on Unix `Uri` parses an ordinary rooted path as an absolute `file:` URI and routing those through `LocalPath` would percent-decode and truncate real store names, losing a live store to fix a cosmetic one. - **`ashlar doctor --format-json` now emits the JSON report** instead of parsing the global flag and dropping it. `doctor` spells its own switch `--json`; `--format-json` is the CLI-wide flag and is global, so it parsed here and was silently ignored, handing a caller who was piping stdout into a parser eight lines of prose. Both spellings now reach the report that already existed. +- Records that named the wrong runner or gate: `ci/test-ownership.tsv`, the SPEC-006 conformance paragraph, the May readiness sign-off sheets (each PASS now annotated with its evidence), two docs that placed `MeshTaskExecutionServiceTests` wrongly, and the release checklist, which pointed nuget.org Trusted Publishing at `release.yml` instead of `reusable-release-nuget.yml`. +- `Ashlar.Brick.Contracts`: the 18 public generative-profile port types in `Ashlar.Core.Domain.Bricks.Ports` (public since 0.1.0) are now recorded in `PublicAPI.Shipped.txt` and covered by the stable-tier compatibility promise; a file-wide `#pragma warning disable RS0016` had excluded them from public-API tracking. No API change. ### Security @@ -455,6 +455,10 @@ The remedy is **`ashlar gates sign-activate --repair`**, which re-mints the inve - **A symlinked `.ashpkg` is refused outright** — fail-closed, and **breaking** for anyone who links packages into a mesh store. The link is not followed, because following it *is* the bypass. The refusal names the way out: point the path argument or `--from` at the directory the package really lives in, or copy the file into the store rather than linking it. This applies to the `.ashpkg` **file** only — a store **directory** that is itself a symlink still works. - **One planted file can no longer deny every package behind it in a `pkg pull`.** A mesh store is a plain synced directory, so anyone who can write to the share can drop a file into it. Every per-row failure — the read, the gate submission (which sat outside the guard and still aborted the whole pass, e.g. when the receiving project's `.ashlar` cannot be created), and the row rendering — is now a `REFUSED` row that increments the counter and lets the pass continue. A refusal never becomes a silent skip: the pass still exits 65 and the summary names the count. A cancelled pass still cancels. - **Sender-chosen text in every `.ashpkg` read refusal is escaped before it reaches the terminal.** Two parts of those lines are chosen by whoever wrote to the share and not by the operator: the **filename** — which is also what a `× REFUSED` row falls back to when the package did not parse and there is no summary to print — and the **symlink target** quoted inside the refusal, which reaches the operator even when every character they typed was honest (`pkg import store/x.ashpkg`, on a link someone planted). A bare CR, LF, or ANSI/OSC escape in either could repaint the line into a counterfeit `✓ ADMITTED … sealed by `, defeating the sealer fingerprint — the one part of a row a sender cannot choose. C0 and C1 controls (including U+009B, a CSI with no ESC in front of it), DEL, and bidi overrides are now replaced with U+FFFD, on every `pkg pull` refusal row and on the `import` / `show` / `publish` refusals written to stderr; a legitimate non-ASCII package name passes through byte-identical. The pre-existing sites that print a **verified** package's summary, `proposedBy`, or a course detail on the success paths are unchanged and tracked in #483; the escaping helper is shared so that issue can reuse it. +- **The commercial Fleet.Host no longer serves Swagger outside Development.** It served the OpenAPI document (`/swagger/v1/swagger.json` and `.yaml`) and the UI under `/swagger`, unauthenticated, in every environment, including the `Dockerfile.fleet-host` image in its default `Production` environment. Opt in with `Ashlar__Api__EnableSwagger=true`, as on Ashlar.API. Starting an image with `ASPNETCORE_ENVIRONMENT=Testing` still serves it, through the `appsettings.Testing.json` it inherits from Ashlar.API (`docs/DEPLOYMENT.md`). +- **`OutputPathSandboxed` no longer approves an output path that merely shares its `OutputRoot`'s name as a prefix** (`-evil`, `x`, `.dll`), and it refuses a non-string `output` argument instead of throwing. On Linux, `OutputPathSandboxed`, `PathAllowlist`'s absolute-path check and `WorkspacePathPolicy` now compare case-sensitively. Containment is one public helper, `Ashlar.Abstractions.Paths.PathContainment` (`IsWithin`, `IsStrictlyWithin`, `PlatformComparison`). +- **Missing sensitivity data fails closed.** An omitted or unrecognised caller clearance is the floor (Public); an unlabelled record is readable only at the top clearance; the `rag:search` audit records the clearance actually applied (`caller_tier`, `caller_tier_basis`). Tool calls from an agent the exfiltration policy cannot identify, or that has no policy, are held to the most restrictive policy; `ashlar self-extend`, whose snapshot agent id is not a registered agent, is therefore refused the LLM, web-search and export tool ids (its repo.fs, dotnet and forge tools are unaffected). With `ASHLAR_TRUST_ENABLED=1`, `ashlar improve` now blocks a prompt carrying PII and redacts filterable PII; its sanitizer had no content filter and passed every prompt through. The CLI and library effects are under Changed. +- **An agent config with no `ExfiltrationPolicy` section now gets the restrictions its `MaxDataSensitivity` implies.** The derivation never fired, so a TopSecret agent ran with every Block flag false. Confidential blocks external LLMs and network exports, Secret also web search, and TopSecret also requires local-only; Public and Internal get no flags, so no shipped example config changes. **This loosens one bound:** the derived `MaxAllowedLevel` rises from the accidental default `Public` to the agent's `MaxDataSensitivity`, and that value bounds the levels a machine-spawned child may carry, so a child of such a parent at Internal or above may now carry levels up to the parent's own (before: refused above Public). The child must still match the derived Block flags, and its own `MaxDataSensitivity` is still capped at the parent's. An agent the binder cannot read is still left out, and is now logged as a warning naming its configuration path. ## [0.1.2] - 2026-09-04 diff --git a/application/src/Ashlar.CLI/Commands/BackgroundAgent/RAGCommand.cs b/application/src/Ashlar.CLI/Commands/BackgroundAgent/RAGCommand.cs index bb72aeccc..459b08e49 100644 --- a/application/src/Ashlar.CLI/Commands/BackgroundAgent/RAGCommand.cs +++ b/application/src/Ashlar.CLI/Commands/BackgroundAgent/RAGCommand.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Logging; +using Ashlar.BackgroundAgents.DataSensitivity; using Ashlar.BackgroundAgents.RAG; namespace Ashlar.CLI.Commands.BackgroundAgent; @@ -7,25 +8,48 @@ namespace Ashlar.CLI.Commands.BackgroundAgent; /// /// CLI command handler for RAG operations (index, search, stats, clear). /// +/// +/// Both commands take the sensitivity decision out of the defaults. +/// rag index REFUSES to run without --sensitivity. The alternative was to +/// default an omitted label to the top level, and that was rejected: a knowledge base indexed that +/// way is invisible to every search that does not pass the top clearance, so the omission would +/// surface later as "search finds nothing" with no pointer back to its cause. It used to default to +/// Public, which published every file. A refusal at index time names the missing decision at the +/// moment it is being made. (The library layer underneath still treats an omitted label as the +/// most restrictive level, for programmatic callers.) +/// rag search without --max-sensitivity searches at the FLOOR (Public) and says +/// so; it used to search at TopSecret, returning the whole corpus. A --max-sensitivity that +/// names no known level is refused rather than floored, so a misspelt clearance cannot read as +/// "nothing matched". +/// public class RAGCommand { private readonly IRAGService _ragService; private readonly IKnowledgeBaseIndexer _indexer; private readonly ILogger _logger; + private readonly IDataSensitivityRegistry _sensitivityRegistry; /// Creates a new RAGCommand instance. + /// RAG service. + /// Knowledge base indexer. + /// Logger. + /// Registry used to recognise level names; the five primitive + /// levels when null. public RAGCommand( IRAGService ragService, IKnowledgeBaseIndexer indexer, - ILogger logger) + ILogger logger, + IDataSensitivityRegistry? sensitivityRegistry = null) { _ragService = ragService ?? throw new ArgumentNullException(nameof(ragService)); _indexer = indexer ?? throw new ArgumentNullException(nameof(indexer)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _sensitivityRegistry = sensitivityRegistry ?? new DataSensitivityRegistry(); } /// - /// Index files or directories into the RAG store. + /// Index files or directories into the RAG store. is REQUIRED: + /// without it nothing is indexed and the exit code is 1. /// public async Task IndexAsync(IEnumerable paths, string? sensitivity, bool formatJson, CancellationToken ct = default) { @@ -40,6 +64,23 @@ public async Task IndexAsync(IEnumerable paths, string? sensitivity Console.Error.WriteLine("At least one path required"); return 1; } + + if (string.IsNullOrWhiteSpace(sensitivity)) + { + var error = "--sensitivity is required: choose the level these documents are cleared at (" + + KnownLevels() + "). Nothing was indexed. Unlabelled documents are no longer " + + "stored as Public; the label has to be a deliberate choice."; + if (formatJson) + Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = false, error })); + else + Console.Error.WriteLine(error); + return 1; + } + + if (_sensitivityRegistry.GetByName(sensitivity) is null) + { + return UnknownLevel("--sensitivity", sensitivity, formatJson); + } var count = await _indexer.IndexDocumentsAsync(pathList, sensitivity, ct).ConfigureAwait(false); if (formatJson) Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = true, paths = pathList, indexedCount = count })); @@ -59,7 +100,8 @@ public async Task IndexAsync(IEnumerable paths, string? sensitivity } /// - /// Search the RAG store. + /// Search the RAG store. An omitted searches at the floor + /// (Public); one that names no known level is refused with exit code 1. /// public async Task SearchAsync(string query, int maxResults, double minScore, string? maxSensitivity, bool formatJson, CancellationToken ct = default) { @@ -73,16 +115,33 @@ public async Task SearchAsync(string query, int maxResults, double minScore Console.Error.WriteLine("Query required"); return 1; } + string clearance; + if (string.IsNullOrWhiteSpace(maxSensitivity)) + { + clearance = _sensitivityRegistry.Floor().Value; + } + else if (_sensitivityRegistry.GetByName(maxSensitivity) is { } level) + { + clearance = level.Value; + } + else + { + return UnknownLevel("--max-sensitivity", maxSensitivity, formatJson); + } + var results = await _ragService.SearchAsync(query, maxResults, minScore, maxSensitivity, ct).ConfigureAwait(false); if (formatJson) { var items = results.Select(r => new { r.Id, r.Text, r.Score, r.SensitivityLevelName }).ToList(); var options = new JsonSerializerOptions { WriteIndented = true }; - Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = true, query, results = items }, options)); + Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = true, query, clearance, results = items }, options)); } else { Console.Out.WriteLine($"RAG Search: \"{query}\""); + Console.Out.WriteLine(string.IsNullOrWhiteSpace(maxSensitivity) + ? $" Clearance: {clearance} (the floor: --max-sensitivity not given)" + : $" Clearance: {clearance}"); foreach (var r in results) { Console.Out.WriteLine($" Score: {r.Score:F3} | {r.SensitivityLevelName ?? "-"} | {r.Id}"); @@ -152,6 +211,19 @@ public async Task ClearAsync(bool formatJson, CancellationToken ct = defaul } } + private string KnownLevels() => + string.Join(", ", _sensitivityRegistry.GetAll().Select(l => l.Value)); + + private int UnknownLevel(string option, string value, bool formatJson) + { + var error = $"{option} '{value}' is not a known sensitivity level (known: {KnownLevels()})."; + if (formatJson) + Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = false, error })); + else + Console.Error.WriteLine(error); + return 1; + } + private static string Truncate(string text, int maxLen) { if (string.IsNullOrEmpty(text)) return string.Empty; diff --git a/application/src/Ashlar.CLI/Commands/ImproveCommand.cs b/application/src/Ashlar.CLI/Commands/ImproveCommand.cs index cf9897e1e..65ba51a7b 100644 --- a/application/src/Ashlar.CLI/Commands/ImproveCommand.cs +++ b/application/src/Ashlar.CLI/Commands/ImproveCommand.cs @@ -87,33 +87,31 @@ public sealed class ImproveCommand : Command }); } - private static async Task ExecuteAsync(string? path, bool dryRun, string autonomy = "supervised", bool yes = false, bool skipRegression = false, string? storePathOverride = null, bool self = false, string? holdoutFilter = null, bool fromObservation = false, int observationDays = 7) + /// + /// Registers the provider-factory chain the improve loop uses, sanitized when + /// . + /// + /// + /// With trust enabled this used to register CloudSanitizationProxy by type alone. Its + /// constructor takes every dependency as optional, so DI built it with NO content filter, NO + /// taxonomy and NO audit log, and the proxy then passed every prompt through unaudited: the + /// trust switch wrapped the provider in a sanitizer that sanitized nothing. It now takes the + /// same trust composition the kernel uses (AddTrustServices, provider registration + /// skipped because this method builds that chain itself), which supplies the filter, the + /// taxonomy and the audit log. The proxy also refuses outright when it has no filter, so a + /// future registration that drops the filter blocks cloud calls instead of silently allowing them. + /// + internal static void RegisterProviderFactories(IServiceCollection serviceCollection, bool trustEnabled) { - var repoRoot = RepoPathResolver.FindRepoRoot(); - var storePath = !string.IsNullOrWhiteSpace(storePathOverride) - ? Path.Combine(Path.GetFullPath(storePathOverride), "ashlar-patterns.db") - : Path.Combine(RepoPathResolver.ResolveStateDirectory(repoRoot), "ashlar-patterns.db"); - var targetPath = path ?? RepoPathResolver.FindBlock1ObservationPath(repoRoot); - - var trustEnabled = string.Equals( - Environment.GetEnvironmentVariable("ASHLAR_TRUST_ENABLED"), "1", - StringComparison.OrdinalIgnoreCase); - - var serviceCollection = new ServiceCollection() - .AddLogging(b => b.AddConsole()) - .AddCodeAnalyzers() - .AddAdaptationInfrastructure(storePath) - .AddAdaptationBricks(typeof(OWASPScannerBrick)) - .AddSelfContextInfrastructure(storePath) - .AddSharedAdaptationCache(); - serviceCollection.AddSingleton(); - + if (trustEnabled) { - serviceCollection.AddSingleton(); - + Ashlar.BackgroundAgents.ServiceCollectionExtensions.AddTrustServices( + serviceCollection, + useSanitizingProviderFactory: false, + skipProviderRegistration: true); + // Register ONE SanitizingProviderFactory wrapping Infrastructure ProviderFactory // Infrastructure chain stays Infrastructure; adapt at edge for Application port serviceCollection.AddSingleton(sp => @@ -124,7 +122,7 @@ private static async Task ExecuteAsync(string? path, bool dryRun, string autonom sp.GetRequiredService(), sp.GetRequiredService>()); }); - + // Dual-register: SAME Sanitizing instance for both Infra and App ports serviceCollection.AddSingleton( sp => sp.GetRequiredService()); @@ -142,6 +140,29 @@ private static async Task ExecuteAsync(string? path, bool dryRun, string autonom return new Ashlar.Infrastructure.Adapters.ProviderFactoryAdapter(infraFactory); }); } + } + + private static async Task ExecuteAsync(string? path, bool dryRun, string autonomy = "supervised", bool yes = false, bool skipRegression = false, string? storePathOverride = null, bool self = false, string? holdoutFilter = null, bool fromObservation = false, int observationDays = 7) + { + var repoRoot = RepoPathResolver.FindRepoRoot(); + var storePath = !string.IsNullOrWhiteSpace(storePathOverride) + ? Path.Combine(Path.GetFullPath(storePathOverride), "ashlar-patterns.db") + : Path.Combine(RepoPathResolver.ResolveStateDirectory(repoRoot), "ashlar-patterns.db"); + var targetPath = path ?? RepoPathResolver.FindBlock1ObservationPath(repoRoot); + + var trustEnabled = string.Equals( + Environment.GetEnvironmentVariable("ASHLAR_TRUST_ENABLED"), "1", + StringComparison.OrdinalIgnoreCase); + + var serviceCollection = new ServiceCollection() + .AddLogging(b => b.AddConsole()) + .AddCodeAnalyzers() + .AddAdaptationInfrastructure(storePath) + .AddAdaptationBricks(typeof(OWASPScannerBrick)) + .AddSelfContextInfrastructure(storePath) + .AddSharedAdaptationCache(); + + RegisterProviderFactories(serviceCollection, trustEnabled); if (self) { diff --git a/application/src/Ashlar.CLI/Program.BackgroundAgentCommands.cs b/application/src/Ashlar.CLI/Program.BackgroundAgentCommands.cs index 5a42598f1..ce1562f69 100644 --- a/application/src/Ashlar.CLI/Program.BackgroundAgentCommands.cs +++ b/application/src/Ashlar.CLI/Program.BackgroundAgentCommands.cs @@ -644,7 +644,7 @@ private static Command BuildBackgroundAgentCommand(Option jsonOpt) // ashlar background-agent rag var ragCmd = new Command("rag", "RAG (Retrieval Augmented Generation) operations"); var ragIndexPathsOpt = new Option("--paths", "Paths to index (files or directories)") { IsRequired = true, AllowMultipleArgumentsPerToken = true }; - var ragIndexSensOpt = new Option("--sensitivity", "Default sensitivity level for indexed documents"); + var ragIndexSensOpt = new Option("--sensitivity", "Sensitivity level for the indexed documents (required; e.g. Public, Internal, Confidential, Secret, TopSecret). Omitting it indexes nothing"); var ragIndexCmd = new Command("index", "Index paths into RAG store") { ragIndexPathsOpt, ragIndexSensOpt }; ragIndexCmd.SetHandler( /// Async. @@ -664,7 +664,7 @@ private static Command BuildBackgroundAgentCommand(Option jsonOpt) ragSearchQueryOpt, new Option("--max-results", () => 5, "Max results"), new Option("--min-score", () => 0.0, "Min similarity score"), - new Option("--max-sensitivity", "Max sensitivity level for results") + new Option("--max-sensitivity", "Max sensitivity level for results (your clearance). Omitted means the floor, Public; an unknown level is refused") }; ragSearchCmd.SetHandler( /// Async. diff --git a/application/src/Ashlar.Tests.CLI/Tests/Commands/ImproveCommandTrustWiringTests.cs b/application/src/Ashlar.Tests.CLI/Tests/Commands/ImproveCommandTrustWiringTests.cs new file mode 100644 index 000000000..63c65a329 --- /dev/null +++ b/application/src/Ashlar.Tests.CLI/Tests/Commands/ImproveCommandTrustWiringTests.cs @@ -0,0 +1,63 @@ +using Ashlar.BackgroundAgents.Trust; +using Ashlar.CLI.Commands; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Ashlar.Tests.CLI.Tests.Commands; + +/// +/// With trust enabled, ashlar improve used to register CloudSanitizationProxy by type alone. +/// Every constructor dependency is optional, so DI built it with no content filter, and the proxy +/// passed every prompt through: "trust enabled" wrapped the provider in a sanitizer that sanitized +/// nothing. These resolve the composition the command actually builds. +/// +public sealed class ImproveCommandTrustWiringTests +{ + [Fact] + public void With_trust_enabled_the_proxy_has_a_content_filter() + { + using var provider = Build(trustEnabled: true); + var proxy = provider.GetRequiredService(); + + // A clean prompt passes. With no filter the proxy now blocks EVERYTHING, so this is the + // assertion that tells "a filter is wired" apart from "the proxy blocks by default". + proxy.SanitizeForCloud(new OutgoingContext { SystemPrompt = "Analyze", UserPrompt = "What is the weather?" }) + .Allowed.Should().BeTrue("a proxy WITH a content filter lets a clean prompt through"); + + // And a prompt with PII is blocked FOR the PII -- by the filter, not for want of one. + var blocked = proxy.SanitizeForCloud(new OutgoingContext { SystemPrompt = "Analyze", UserPrompt = "mail someone@example.com" }); + blocked.Allowed.Should().BeFalse(); + blocked.BlockReason.Should().Contain("PII"); + } + + [Fact] + public void With_trust_enabled_both_provider_ports_are_the_one_sanitizing_factory() + { + using var provider = Build(trustEnabled: true); + + var infra = provider.GetRequiredService(); + infra.Should().BeOfType(); + provider.GetRequiredService().Should().BeSameAs(infra); + } + + // POSITIVE CONTROL for the wiring: with trust disabled nothing sanitizing is registered, so the + // two tests above are about the trust branch and not about something every build registers. + [Fact] + public void With_trust_disabled_no_sanitizer_is_registered() + { + using var provider = Build(trustEnabled: false); + + provider.GetService().Should().BeNull(); + provider.GetRequiredService() + .Should().BeOfType(); + } + + private static ServiceProvider Build(bool trustEnabled) + { + var services = new ServiceCollection(); + services.AddLogging(); + ImproveCommand.RegisterProviderFactories(services, trustEnabled); + return services.BuildServiceProvider(); + } +} diff --git a/application/src/Ashlar.Tests.CLI/Tests/Commands/RAGCommandFailClosedTests.cs b/application/src/Ashlar.Tests.CLI/Tests/Commands/RAGCommandFailClosedTests.cs new file mode 100644 index 000000000..a9ed313e8 --- /dev/null +++ b/application/src/Ashlar.Tests.CLI/Tests/Commands/RAGCommandFailClosedTests.cs @@ -0,0 +1,151 @@ +using Ashlar.BackgroundAgents.RAG; +using Ashlar.CLI.Commands.BackgroundAgent; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Ashlar.Tests.CLI.Tests.Commands; + +/// +/// rag index no longer stores an unlabelled file as Public, and rag search no longer +/// searches an omitted clearance as TopSecret. The index side REFUSES without a label (rather than +/// defaulting to the top, which would make the knowledge base silently unsearchable); the search +/// side passes the omission through to the store, which applies the floor. +/// +public sealed class RAGCommandFailClosedTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Index_without_a_sensitivity_is_refused_and_indexes_nothing(string? sensitivity) + { + var indexer = new RecordingIndexer(); + var command = new RAGCommand(new RecordingRag(), indexer, NullLogger.Instance); + + var (exit, stdout) = await CaptureAsync(() => command.IndexAsync(new[] { "docs/" }, sensitivity, formatJson: true)); + + exit.Should().Be(1); + indexer.Calls.Should().BeEmpty("nothing may be indexed without a deliberate label"); + stdout.Should().Contain("--sensitivity is required"); + } + + [Fact] + public async Task Index_with_an_unknown_sensitivity_is_refused_and_indexes_nothing() + { + var indexer = new RecordingIndexer(); + var command = new RAGCommand(new RecordingRag(), indexer, NullLogger.Instance); + + var (exit, stdout) = await CaptureAsync(() => command.IndexAsync(new[] { "docs/" }, "Secrte", formatJson: false)); + + exit.Should().Be(1); + indexer.Calls.Should().BeEmpty(); + stdout.Should().Contain("'Secrte' is not a known sensitivity level"); + } + + // POSITIVE CONTROL for both refusals: a known label indexes, and reaches the indexer as given. + [Fact] + public async Task Index_with_a_known_sensitivity_indexes_with_that_label() + { + var indexer = new RecordingIndexer(); + var command = new RAGCommand(new RecordingRag(), indexer, NullLogger.Instance); + + var (exit, _) = await CaptureAsync(() => command.IndexAsync(new[] { "docs/" }, "Secret", formatJson: true)); + + exit.Should().Be(0); + indexer.Calls.Should().ContainSingle().Which.Should().Be("Secret"); + } + + [Fact] + public async Task Search_without_a_clearance_passes_the_omission_through_and_reports_the_floor() + { + var rag = new RecordingRag(); + var command = new RAGCommand(rag, new RecordingIndexer(), NullLogger.Instance); + + var (exit, stdout) = await CaptureAsync(() => command.SearchAsync("incident playbook", 5, 0.0, null, formatJson: true)); + + exit.Should().Be(0); + rag.Clearances.Should().ContainSingle().Which.Should().BeNull( + "the CLI must not substitute a clearance for an omitted one -- it used to reach the store as TopSecret"); + stdout.Should().Contain("\"clearance\": \"Public\""); + } + + [Fact] + public async Task Search_with_an_unknown_clearance_is_refused_and_searches_nothing() + { + var rag = new RecordingRag(); + var command = new RAGCommand(rag, new RecordingIndexer(), NullLogger.Instance); + + var (exit, stdout) = await CaptureAsync(() => command.SearchAsync("incident playbook", 5, 0.0, "Secrte", formatJson: false)); + + exit.Should().Be(1); + rag.Clearances.Should().BeEmpty(); + stdout.Should().Contain("'Secrte' is not a known sensitivity level"); + } + + // End to end through a real store: an omitted clearance does not reach the Secret document. + [Fact] + public async Task Search_without_a_clearance_does_not_return_a_Secret_document() + { + var rag = new RAGService(new InMemoryVectorStore(), new TokenEmbeddingGenerator()); + await rag.IndexAsync("pub", "shared incident playbook", "Public"); + await rag.IndexAsync("sec", "shared incident playbook", "Secret"); + var command = new RAGCommand(rag, new KnowledgeBaseIndexer(rag), NullLogger.Instance); + + var (omitted, omittedOut) = await CaptureAsync(() => command.SearchAsync("incident playbook", 5, 0.0, null, formatJson: true)); + var (cleared, clearedOut) = await CaptureAsync(() => command.SearchAsync("incident playbook", 5, 0.0, "Secret", formatJson: true)); + + omitted.Should().Be(0); + omittedOut.Should().Contain("\"pub\"").And.NotContain("\"sec\""); + cleared.Should().Be(0); + clearedOut.Should().Contain("\"sec\"", "positive control: the Secret document is there for a Secret clearance"); + } + + private static async Task<(int Exit, string Stdout)> CaptureAsync(Func> run) + { + var stdout = new StringWriter(); + var stderr = new StringWriter(); + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var exit = await run(); + return (exit, stdout.ToString() + stderr.ToString()); + } + finally + { + Console.SetOut(ConsoleCapture.Out); + Console.SetError(ConsoleCapture.Error); + } + } + + private sealed class RecordingIndexer : IKnowledgeBaseIndexer + { + public List Calls { get; } = new(); + + public Task IndexDocumentsAsync(IEnumerable paths, string? defaultSensitivityLevelName, CancellationToken cancellationToken = default) + { + Calls.Add(defaultSensitivityLevelName); + return Task.FromResult(1); + } + } + + private sealed class RecordingRag : IRAGService + { + public List Clearances { get; } = new(); + + public Task> SearchAsync(string query, int maxResults, double minScore, string? maxSensitivityLevelName, CancellationToken cancellationToken = default) + { + Clearances.Add(maxSensitivityLevelName); + return Task.FromResult>(Array.Empty()); + } + + public Task IndexAsync(string id, string text, string? sensitivityLevelName, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task RemoveAsync(string id, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task ClearAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetDocumentCountAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); + } +} diff --git a/ci/cert-gate-assertions.md b/ci/cert-gate-assertions.md index 447cfd334..4bb79b46e 100644 --- a/ci/cert-gate-assertions.md +++ b/ci/cert-gate-assertions.md @@ -57,6 +57,8 @@ away, can see what they would be turning off. | **Validation needs readable, consistent result evidence.** Missing results require an explicit no-tests-selected message; empty TRX files require a completed summary and zero counters; populated files must reconcile parsed rows with summary counters. | `ValidationEvidenceCertificationTests` | A clean process exit can otherwise report an invented passing test, or malformed and partial results can look like an empty successful selection. | | **The template discloses its current trust boundary.** Each consumer entry point (`host/Program.cs`, `host/README.md`, `CONSUMING.md`) must exist and state that the template executes an unverified brick. | `ConsumerTemplateTrustBoundaryTests` | This is a disclosure contract, not a classifier or proof of host behavior. `JudgedArtifactIsTheExecutedArtifactTests` separately exercises verification and activation using the in-process certifier's artifact; package-consumer reconstruction and host registration are outside that test's scope. | | **Validation arguments preserve the caller's selection and process boundaries.** Empty filters retain both default exclusions; nonempty filters are grouped with those exclusions and occupy one argv element. Paths and framework values cannot add process options. The blame-window convention observes the actual argument-list builder. | `ValidationFilterCertificationTests`, `LaneBlameWindowConventionTests` | A quoted command string can reinterpret user values as process options, while an ignored or ungrouped filter runs a different test population. `ValidationServiceAdapterGapCoverageTests` separately runs a real test project and checks its selected result names and counts. | +| **Path containment is one helper, and nothing hand-rolls it.** `Ashlar.Abstractions.Paths.PathContainment` normalises both paths with `Path.GetFullPath`, drops trailing separators, refuses anything it cannot normalise or that keeps a `.`/`..` segment, and admits only the root itself (`IsWithin`) or the root FOLLOWED BY A SEPARATOR, with the platform's default case rule (ordinal-ignore-case on Windows and macOS, ordinal elsewhere) unless the caller passes an ordinal comparison; it is lexical and does not follow links, and a fact pins that. An absolute table (root itself, trailing separators, `..` in and out, doubled separators, sibling-prefix directory/file/bare, a backslash-dot-dot suffix, a case variant, a filesystem root) runs against the helper, `OutputPathSandboxed` and `PathAllowlist`; a relative table (`../repo-evil`, `../repox`, `../repo.dll` beside a real `/repo`, with those siblings on disk) runs against `ToolSandbox`'s read and write resolvers, `repo.fs.read` and `repo.fs.list`. A production `.StartsWith(` counts as a hand-rolled check when its non-literal first argument or its receiver has full-path provenance: it spells `GetFullPath(`, a directory separator or `new FileInfo(`/`new DirectoryInfo(`, or names an identifier assigned from one, assigned ONE path-building step from one (`Path.Combine`, `+ "/"`, `$"{root}/"`, a plain alias), or bound as a parameter by a call in the same file that passes one; or when it compares with `PlatformComparison`, or its interpolated literal argument embeds such a path. Each file's count must EQUAL its pin (two remain: `AshlarEndpoints.cs` 2, `ProjectVerifier.cs` 1), so a new copy and a stale pin both fail. Provenance is by identifier NAME within one file, so the scan does NOT see a helper whose callers live in another file, a root that comes back from a method call, the `FullName` of a `FileInfo` the code receives, or a non-`StartsWith` idiom (`IndexOf(root) == 0`, `string.Compare`, `Uri.IsBaseOf`); 38 controls (19 must count, 16 must not, 3 known misses pinned at zero) fix how it tells a containment check from a string classifier. Test projects are skipped by their `Microsoft.NET.Test.Sdk` reference, not by a directory name containing "Tests". | `PathContainmentConventionTests`, `PathContainmentTests` | `OutputPathSandboxed` compared `GetFullPath(output).StartsWith(GetFullPath(OutputRoot), OrdinalIgnoreCase)` with no separator, so with `OutputRoot` `/out` (as `AgentExecutorAdapter` sets it) it approved `/out-evil/x`, `/outx` and `/out.dll`, and on Linux a case variant of the root; `assembly.decompile` creates and writes its `output`. Ten hand-written copies had five semantics between them, and the next tool would have written an eleventh. A separator-less helper put back into `ToolSandbox` once passed this scan and every suite; the relative table and the parameter rule are what now catch it, and the scan alone is not proof. | +| **Public-API tracking is never switched off, and a deprecation disable names what it is for.** No source disables RS0016, RS0017, RS0026 or RS0027 by pragma, `[SuppressMessage]`/`[UnconditionalSuppressMessage]` or a bare `#pragma warning disable`; no MSBuild file lists them in `NoWarn` or `WarningsNotAsErrors`; no `.editorconfig`/`.globalconfig` lowers them to none, silent or suggestion or sets `dotnet_public_api_analyzer.skip_namespaces` (nor, where the config applies to an analyzer project, lowers the `ApiDesign` category or every analyzer); no analyzer project or `Directory.Build.*` above one sets `RunAnalyzers`/`RunAnalyzersDuringBuild` false or removes an API file from `AdditionalFiles`; and every project referencing `Microsoft.CodeAnalysis.PublicApiAnalyzers` wires both `PublicAPI.*.txt` files as `AdditionalFiles`. Every `#pragma warning disable` of CS0618 or ASHLAREXP001 - in any spelling the compiler accepts, including the bare `618`/`0618` - names, as an exact identifier, an in-tree `[Obsolete]` (CS0618: not `error: true`, no `DiagnosticId`) or `[Experimental(ASHLAREXP001)]` symbol that the code it governs still uses OUTSIDE that symbol's own attributed declaration, and gives a reason of at least three words. A disable that cannot carry that comment - a `[SuppressMessage]` naming either id, a `NoWarn`/`WarningsNotAsErrors` element or attribute listing either id in any MSBuild file, or a `dotnet_diagnostic..severity` of none, silent or suggestion - is a violation too; the two project-wide ones that exist (`Directory.Build.targets`' test-project `ASHLAREXP001` opt-in and the `FirstFlight` spike's) are on a reviewed list that records why. Eight existing pragmas in files outside that change are grandfathered by exact text. Both lists fail in both directions (a new violation, or an entry that no longer matches). Stated floors (2500 C# files, 100 MSBuild files, 6 analyzer projects, 40 experimental names and 1 obsolete name, 10 disable pragmas; measured 3149, 133, 6, 65, 2 and 15 on f0fd3fde - the obsolete floor sits under the measured 2 so that retiring one forwarder is not reported as a broken parser) stop an emptied scan passing; controls pin every banned spelling, immunity to comments and literals, the obsolete-context rule, and the shipped `HostAshlarSdkBuilder.cs` dead pragma verbatim and in its numeric spellings. | `DiagnosticSuppressionConventionTests` | Five `Ashlar.Brick.Contracts` Ports files opened with a file-wide `#pragma warning disable RS0016`, so 18 public types published in `v0.1.0` to `v0.1.2` were tracked by nothing and the stable-tier promise in `docs/SdkCompatibilityPolicy.md` was false for them. `HostAshlarSdkBuilder.cs:7` disabled CS0618 for a forwarder #223 had moved out, and suppressed nothing for 88 days while still reading as a reason. **Lexical only:** this proves a disable names a real symbol the governed code still mentions outside its own context, not that the compiler would report there; an obsolete API declared outside the tree cannot be named and needs a reviewed grandfather entry; CS0612 is not policed; MSBuild is not evaluated, so conditions are ignored and a disable routed through another property (`$(MyList)`) or passed on a command line (`-p:NoWarn=`, `-nowarn:`) is not seen; `TreatWarningsAsErrors`, `WarningLevel` and bulk `dotnet_analyzer_diagnostic.*` settings are not checked for Rule 2. | The gate also carries the certification gate's own teeth, the hot-swap host, the adversarial campaigns, the analyzer fence, sandbox-escape tests and the dogfood suites — 82 `.cs` files in diff --git a/commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs b/commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs index 145a33d0e..725566434 100644 --- a/commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs +++ b/commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs @@ -141,9 +141,40 @@ app.UseAshlarMeshSecurity(); app.UseAshlarApiKeyAuth(); app.UseAshlarCopilotScopedAuthorization(); +// No UsePrivateLicenseGate() here, and that is a recorded decision rather than a gap in the copy +// from Ashlar.API: this host registers neither IPrivateLicenseValidator nor the +// Ashlar:PrivateLicense options, so Ashlar:PrivateLicense:EnforceLicense has no effect on Fleet.Host; +// the enforcement in docs/product-fleet/private-reference-deployment.md runs in that stack's +// Ashlar.API container (.docker/Dockerfile.api). Adding it here is a licensing change with its own +// blast radius (with enforcement on, an expired license would answer 402 to fleet node +// registration), not part of gating Swagger; docs/OpenCoreBoundary.md tracks moving the gate into +// the commercial host. app.UseRateLimiter(); -app.UseSwagger(); -app.UseSwaggerUI(static c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Ashlar Commercial Fleet Host v1")); + +// --- Swagger (OpenAPI document + UI): the same gate as application/src/Ashlar.API/Program.cs --- +// On in Development, otherwise opt-in via Ashlar:Api:EnableSwagger (Ashlar__Api__EnableSwagger=true); +// an explicit false turns it off in Development too. /swagger is outside /api, so neither +// UseAshlarApiKeyAuth nor UseAshlarMeshSecurity ever evaluates it: whenever it is served, it is served +// to anyone who can reach the port, under every AuthorizationMode and AuthorizationScope. This host +// used to call UseSwagger unconditionally, which published its route catalogue in Production. +// The key is also read from appsettings.{Environment}.json files this project does not own: the +// ProjectReference to Ashlar.API copies its appsettings.Development.json and appsettings.Testing.json +// into this host's build and publish output, and the Testing one sets EnableSwagger to true, so +// ASPNETCORE_ENVIRONMENT=Testing serves Swagger from the fleet-host image too. +// Pinned, that exception included, by FleetHostSwaggerExposureTests. +{ + var enableSwagger = app.Configuration.GetValue("Ashlar:Api:EnableSwagger") ?? app.Environment.IsDevelopment(); + if (enableSwagger) + { + app.UseSwagger(); + app.UseSwaggerUI(static c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Ashlar Commercial Fleet Host v1")); + if (!app.Environment.IsDevelopment()) + { + app.Services.GetRequiredService().CreateLogger("Ashlar.Security") + .LogInformation("Swagger UI is enabled outside Development (Ashlar:Api:EnableSwagger=true): /swagger exposes the full route catalogue."); + } + } +} app.MapAshlarEndpoints(); app.MapAshlarCommercialFleetEndpoints(); diff --git a/commercial/src/Ashlar.Commercial.Fleet.Host/appsettings.json b/commercial/src/Ashlar.Commercial.Fleet.Host/appsettings.json index 9f1ddcefc..04e7a85e4 100644 --- a/commercial/src/Ashlar.Commercial.Fleet.Host/appsettings.json +++ b/commercial/src/Ashlar.Commercial.Fleet.Host/appsettings.json @@ -8,6 +8,9 @@ "Ashlar": { "PatternStorePath": "ashlar-patterns.db", "RegisterBackgroundAgentHostedService": true, + "Api": { + "EnableSwagger": null + }, "Security": { "AuthorizationMode": "ApiKey", "AuthorizationScope": "MutatingApi", diff --git a/commercial/tests/Ashlar.Commercial.Tests.Fleet.Host/FleetHostSwaggerExposureTests.cs b/commercial/tests/Ashlar.Commercial.Tests.Fleet.Host/FleetHostSwaggerExposureTests.cs new file mode 100644 index 000000000..94293ce7b --- /dev/null +++ b/commercial/tests/Ashlar.Commercial.Tests.Fleet.Host/FleetHostSwaggerExposureTests.cs @@ -0,0 +1,354 @@ +using System.Collections.Concurrent; +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Ashlar.Commercial.Tests.Fleet.Host; + +/// +/// Fleet.Host serves Swagger exactly where Ashlar.API does: in Development, or anywhere +/// Ashlar:Api:EnableSwagger opts in, and an explicit false wins over the environment. +/// +/// +/// This host's pipeline was copied from application/src/Ashlar.API/Program.cs without the +/// API's Swagger gate, so it called UseSwagger and UseSwaggerUI unconditionally and a +/// Production container answered the OpenAPI document (as JSON and as YAML) and the Swagger UI to anyone +/// who could reach its port. No auth setting changed that: both built-in auth middlewares return early +/// for paths outside /api. Nothing noticed, because both other test classes here run the host in +/// Development, where Swagger is meant to be on. +/// +/// Every case first asserts that the same host answers /health with 200, so a 404 can only +/// mean "not mapped", never "the host did not start". The served cases assert the document is this +/// host's (its title and a commercial fleet route) in both formats, so a 200 from some other handler +/// cannot pass, and so the YAML and redirect assertions in the not-served cases name real routes. The +/// client never follows redirects: the bare /swagger answers 301 when served, and a client that +/// followed it would report the UI page's status instead. +/// +/// Two content roots. uses the Fleet.Host +/// SOURCE directory, which holds only this host's own appsettings.json. The shipped image +/// (.docker/Dockerfile.fleet-host) runs from its publish directory, which also carries +/// Ashlar.API's appsettings.{Development,Testing}.json, copied in through the ProjectReference. +/// Fleet.Host's own build output (bin/<configuration>/<tfm> beside its csproj) +/// carries the same set: its own base file plus the inherited ones. So the build-output cases start the +/// host with that directory as content root, and a change in application/ that turned Swagger on +/// for an environment reaches them, where the source-directory cases cannot see it. Not this test +/// assembly's own directory: that one also receives Ashlar.API's base appsettings.json, which +/// overwrites Fleet.Host's there, so it is not what the image runs; the cases check the base file is +/// identical to the host's source copy before they trust the directory. +/// One inherited file does turn it on today: Ashlar.API's appsettings.Testing.json sets +/// EnableSwagger to true, so ASPNETCORE_ENVIRONMENT=Testing serves Swagger on this image +/// (and on the API image). No deployment sets Testing; the inventory case pins that exception so that +/// removing it, or adding another, is a reviewed change. +/// +/// Enabling Swagger outside Development logs an Ashlar.Security notice, as Ashlar.API does; +/// the cases capture the host's log and pin that it appears exactly when it should. +/// +/// This project is outside the solutions; native readiness builds and runs it on net10.0 through +/// ci verify's validation sweep (docs/CommercialCiCoverage.md). +/// +[Trait("Category", "CommercialFleetHost")] +public sealed class FleetHostSwaggerExposureTests : IClassFixture> +{ + private const string TestApiKey = "fleet-host-swagger-test-key"; + private const string DocumentPath = "/swagger/v1/swagger.json"; + private const string YamlPath = "/swagger/v1/swagger.yaml"; + private const string UiPath = "/swagger/index.html"; + private const string BarePath = "/swagger"; + private const string SecurityCategory = "Ashlar.Security"; + private const string SecurityNotice = "Swagger UI is enabled outside Development"; + + /// + /// The document as JSON and as YAML (Swashbuckle serves both from one route), the UI page, and the + /// bare prefix that redirects to it. + /// + private static readonly string[] SwaggerPaths = [DocumentPath, YamlPath, UiPath, BarePath]; + + private readonly WebApplicationFactory _factory; + + /// Fleet host Swagger exposure tests. + /// Factory. + public FleetHostSwaggerExposureTests(WebApplicationFactory factory) => + _factory = factory; + + /// + /// Fleet.Host's own build output, with every file the build copied beside the host, including the + /// ones inherited from Ashlar.API. It is the project's bin/<configuration>/<tfm>, built + /// with the configuration and framework this test assembly was built with (the ProjectReference + /// builds it). refuses any other directory. + /// + private static (string SourceRoot, string BuildOutput) HostDirectories() + { + var testOutput = new DirectoryInfo(AppContext.BaseDirectory); + var framework = testOutput.Name; + var configuration = testOutput.Parent?.Name + ?? throw new InvalidOperationException($"{testOutput.FullName} has no configuration directory above it."); + for (var directory = testOutput; directory is not null; directory = directory.Parent) + { + var sourceRoot = Path.Combine(directory.FullName, "commercial", "src", "Ashlar.Commercial.Fleet.Host"); + if (File.Exists(Path.Combine(sourceRoot, "Ashlar.Commercial.Fleet.Host.csproj"))) + { + return (sourceRoot, Path.Combine(sourceRoot, "bin", configuration, framework)); + } + } + + throw new InvalidOperationException($"No commercial/src/Ashlar.Commercial.Fleet.Host above {testOutput.FullName}."); + } + + /// + /// The directory must hold the host and the host's OWN base configuration, verbatim, as the + /// published image does; otherwise it stands in for nothing. + /// + /// Fleet.Host's project directory. + /// The directory to vouch for. + private static void AssertIsTheHostsBuildOutput(string sourceRoot, string buildOutput) + { + File.Exists(Path.Combine(buildOutput, "Fleet.Host.dll")).Should().BeTrue($"{buildOutput} must be Fleet.Host's build output"); + var shipped = Path.Combine(buildOutput, "appsettings.json"); + File.Exists(shipped).Should().BeTrue($"{buildOutput} must carry the configuration the host ships with"); + File.ReadAllText(shipped).Should().Be( + File.ReadAllText(Path.Combine(sourceRoot, "appsettings.json")), + $"{shipped} must be Fleet.Host's own appsettings.json, as in the published image, not one another project copied over it"); + } + + /// + /// The security claim itself: a Production host with the shipped configuration (no + /// Ashlar:Api:EnableSwagger override) maps neither the OpenAPI document nor the UI. + /// + [Fact] + public async Task Production_host_with_shipped_configuration_serves_no_swagger() + { + await AssertSwaggerNotServedAsync("Production", enableSwagger: null, contentRoot: null); + } + + /// + /// "Off" is decided by Development, not by Production: Staging is off too. An explicit + /// false stays off (a set value is read, not merely detected), and it also turns Swagger + /// off in Development, because the configured value wins over the environment default. + /// + /// Host environment name. + /// Value for Ashlar:Api:EnableSwagger, or null to leave the shipped value. + [Theory] + [InlineData("Staging", null)] + [InlineData("Production", "false")] + [InlineData("Development", "false")] + public async Task Swagger_is_not_served_outside_development_or_when_explicitly_disabled( + string environment, + string? enableSwagger) + { + await AssertSwaggerNotServedAsync(environment, enableSwagger, contentRoot: null); + } + + /// + /// The image's own view: the host started from the build output, with every inherited + /// appsettings.*.json beside it, serves no Swagger in Production (the image's default, since + /// .docker/Dockerfile.fleet-host sets no ASPNETCORE_ENVIRONMENT) or in Staging. + /// + /// Host environment name. + [Theory] + [InlineData("Production")] + [InlineData("Staging")] + public async Task Host_started_from_its_build_output_serves_no_swagger(string environment) + { + var (sourceRoot, root) = HostDirectories(); + AssertIsTheHostsBuildOutput(sourceRoot, root); + + await AssertSwaggerNotServedAsync(environment, enableSwagger: null, contentRoot: root); + } + + /// + /// Inventory of the environment files the build output carries: the only one whose effective + /// Ashlar:Api:EnableSwagger is true is Testing, inherited from Ashlar.API. A new one + /// (say an appsettings.Production.json added under application/src/Ashlar.API) would + /// reopen Swagger on this image without touching this project; the removal of Testing's is the fix + /// the remarks describe, and should tighten this list to empty. + /// + [Fact] + public void Build_output_turns_swagger_on_only_for_the_inherited_testing_environment() + { + var (sourceRoot, root) = HostDirectories(); + AssertIsTheHostsBuildOutput(sourceRoot, root); + var baseFile = Path.Combine(root, "appsettings.json"); + + var environmentsThatEnableSwagger = Directory.GetFiles(root, "appsettings.*.json") + .Select(static file => Path.GetFileName(file)) + .Where(static name => !string.Equals(name, "appsettings.json", StringComparison.OrdinalIgnoreCase)) + .Where(name => new ConfigurationBuilder() + .AddJsonFile(baseFile) + .AddJsonFile(Path.Combine(root, name)) + .Build() + .GetValue("Ashlar:Api:EnableSwagger") == true) + .Select(static name => name["appsettings.".Length..^".json".Length]) + .Order(StringComparer.Ordinal) + .ToArray(); + + environmentsThatEnableSwagger.Should().Equal( + new[] { "Testing" }, + $"only Ashlar.API's appsettings.Testing.json is known to set Ashlar:Api:EnableSwagger=true among the files in {root}; " + + "any other environment listed here serves Swagger anonymously from the fleet-host image " + + "(a stale file from an earlier local build counts too: clean the output if its source is gone)"); + } + + /// + /// Development serves Swagger by default, and an operator can opt a Production host in with + /// Ashlar__Api__EnableSwagger=true - the same knob, with the same meaning, as Ashlar.API. + /// Opting in outside Development logs the Ashlar.Security notice; Development does not. + /// + /// Host environment name. + /// Value for Ashlar:Api:EnableSwagger, or null to leave the shipped value. + /// How many Ashlar.Security Swagger notices the host must log. + [Theory] + [InlineData("Development", null, 0)] + [InlineData("Production", "true", 1)] + public async Task Swagger_is_served_in_development_or_when_explicitly_enabled( + string environment, + string? enableSwagger, + int expectedNotices) + { + var log = new CapturingLoggerProvider(); + using var factory = CreateFactory(environment, enableSwagger, contentRoot: null, log); + using var client = CreateClient(factory); + await AssertHostIsUpAsync(client, environment); + var setting = $"{environment} with Ashlar:Api:EnableSwagger={enableSwagger ?? "(shipped value)"}"; + + using var document = await client.GetAsync(DocumentPath); + document.StatusCode.Should().Be(HttpStatusCode.OK, $"{setting} must serve the OpenAPI document"); + var json = await document.Content.ReadAsStringAsync(); + json.Should().Contain("\"openapi\"", "the body must be an OpenAPI document"); + json.Should().Contain("Ashlar Commercial Fleet Host", "the document must be this host's, not another handler's 200"); + json.Should().Contain("/api/mesh/fleet/nodes", "the document describes the commercial fleet routes this host maps"); + + using var yaml = await client.GetAsync(YamlPath); + yaml.StatusCode.Should().Be(HttpStatusCode.OK, $"{setting} serves the same document as YAML"); + var yamlBody = await yaml.Content.ReadAsStringAsync(); + yamlBody.Should().Contain("openapi:", "the YAML body must be an OpenAPI document"); + yamlBody.Should().Contain("Ashlar Commercial Fleet Host", "the YAML must be this host's document"); + yamlBody.Should().Contain("/api/mesh/fleet/nodes", "the YAML describes the same routes as the JSON"); + + using var ui = await client.GetAsync(UiPath); + ui.StatusCode.Should().Be(HttpStatusCode.OK, "the Swagger UI is served alongside the document"); + // Not ContentType?.MediaType.Should(): a null-conditional would skip the assertion entirely. + ui.Content.Headers.ContentType.Should().NotBeNull(); + ui.Content.Headers.ContentType!.MediaType.Should().Be("text/html"); + + using var bare = await client.GetAsync(BarePath); + bare.StatusCode.Should().Be(HttpStatusCode.MovedPermanently, "the bare /swagger redirects to the UI page when served"); + bare.Headers.Location.Should().NotBeNull(); + bare.Headers.Location!.OriginalString.Should().EndWith("swagger/index.html"); + + CountSecurityNotices(log).Should().Be( + expectedNotices, + $"{setting} must log the {SecurityCategory} notice exactly when Swagger is on outside Development"); + } + + private async Task AssertSwaggerNotServedAsync(string environment, string? enableSwagger, string? contentRoot) + { + var log = new CapturingLoggerProvider(); + using var factory = CreateFactory(environment, enableSwagger, contentRoot, log); + using var client = CreateClient(factory); + await AssertHostIsUpAsync(client, environment); + if (contentRoot is not null) + { + // UseContentRoot must have reached the host, or this case is the source-directory case again. + factory.Services.GetRequiredService().ContentRootPath + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Should().Be(contentRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + } + + var setting = $"{environment} with Ashlar:Api:EnableSwagger={enableSwagger ?? "(shipped value)"}" + + (contentRoot is null ? string.Empty : $" and content root {contentRoot}"); + // Every path is probed before anything is asserted, so a failure names each one that answered. + var answered = new List(); + foreach (var path in SwaggerPaths) + { + using var response = await client.GetAsync(path); + if (response.StatusCode != HttpStatusCode.NotFound) + { + answered.Add($"{path} answered {(int)response.StatusCode}"); + } + } + + // Joined, not answered.Should().BeEmpty(): FluentAssertions reports only the first item of a + // non-empty collection, which would hide every path after the first that answered. + string.Join("; ", answered).Should().BeEmpty( + $"no Swagger path may be mapped in {setting}: " + + "/swagger is outside /api, so no auth middleware stands in front of it and whatever serves it serves it anonymously"); + + CountSecurityNotices(log).Should().Be(0, $"{setting} serves no Swagger, so there is nothing to warn about"); + } + + private static async Task AssertHostIsUpAsync(HttpClient client, string environment) + { + using var health = await client.GetAsync("/health"); + health.StatusCode.Should().Be( + HttpStatusCode.OK, + $"the {environment} host must be up before its /swagger answer means anything"); + } + + private static HttpClient CreateClient(WebApplicationFactory factory) => + factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + private static int CountSecurityNotices(CapturingLoggerProvider log) => + log.Entries.Count(static e => + e.Category == SecurityCategory + && e.Level == LogLevel.Information + && e.Message.Contains(SecurityNotice, StringComparison.Ordinal)); + + private WebApplicationFactory CreateFactory( + string environment, + string? enableSwagger, + string? contentRoot, + CapturingLoggerProvider log) => + _factory.WithWebHostBuilder(builder => + { + builder.UseEnvironment(environment); + if (contentRoot is not null) + { + builder.UseContentRoot(contentRoot); + } + + builder.UseSetting("Ashlar:Security:ApiKey", TestApiKey); + if (enableSwagger is not null) + { + builder.UseSetting("Ashlar:Api:EnableSwagger", enableSwagger); + } + + builder.ConfigureServices(services => services.AddSingleton(log)); + }); + + /// Records every log entry the host's logger factory lets through its configured filters. + private sealed class CapturingLoggerProvider : ILoggerProvider + { + private readonly ConcurrentQueue<(string Category, LogLevel Level, string Message)> _entries = new(); + + public IReadOnlyCollection<(string Category, LogLevel Level, string Message)> Entries => _entries.ToArray(); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, _entries); + + public void Dispose() + { + } + + private sealed class CapturingLogger( + string category, + ConcurrentQueue<(string Category, LogLevel Level, string Message)> entries) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + entries.Enqueue((category, logLevel, formatter(state, exception))); + } + } +} diff --git a/consumer-template/CONSUMING.md b/consumer-template/CONSUMING.md index 79738fb8f..0f156e38f 100644 --- a/consumer-template/CONSUMING.md +++ b/consumer-template/CONSUMING.md @@ -4,7 +4,7 @@ Starter pins for **ashlar-ai-director**-style repos: authored brick + thin host **Package version:** `0.2.0` — the current nuget.org release (`ci/published-version`). Repo `VERSION` may already read ahead of a release that has not been published; do not treat it as the public pin. -**On nuget.org since v0.1.1 (2026-09-01); current pin is v0.2.0 (2026-09-29).** The full `Ashlar.*` graph (including `Ashlar.CLI`, `Ashlar.Authoring`, `Ashlar.Hosting.Bundle`) restores from plain nuget.org — no staging feed needed. The living proof is [github.com/IanFrelinger/ashlar-release-manager](https://github.com/IanFrelinger/ashlar-release-manager), whose CI restores from nuget.org and nothing else. A staging feed (below) remains an option for pre-release testing; inside a checkout, a `ProjectReference` into `src/` (as in `samples/hello-brick/`) needs no feed at all. +**On nuget.org since v0.1.1 (2026-09-01); current pin is v0.2.0 (2026-10-01).** The full `Ashlar.*` graph (including `Ashlar.CLI`, `Ashlar.Authoring`, `Ashlar.Hosting.Bundle`) restores from plain nuget.org — no staging feed needed. The living proof is [github.com/IanFrelinger/ashlar-release-manager](https://github.com/IanFrelinger/ashlar-release-manager), whose CI restores from nuget.org and nothing else. A staging feed (below) remains an option for pre-release testing; inside a checkout, a `ProjectReference` into `src/` (as in `samples/hello-brick/`) needs no feed at all. ## Package pins (`0.2.0`) diff --git a/docs/Configuration.md b/docs/Configuration.md index 38cd75e2b..ecbf8995d 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -82,7 +82,7 @@ Containers: the API and CLI images set `ASHLAR_STATE_DIR=/data/state` (owned by | Variable / config key | Description | Default | |-----------------------|-------------|---------| -| `Ashlar__Api__EnableSwagger` | Serve `/swagger` (UI) and `/swagger/v1/swagger.json`. The document enumerates every mapped route and schema, so it is off outside `Development` unless set | `true` in `Development`, else `false` | +| `Ashlar__Api__EnableSwagger` | Serve `/swagger` (UI) and `/swagger/v1/swagger.json`. The document enumerates every mapped route and schema, so it is off outside `Development` unless set. The commercial Fleet.Host honours the same key with the same default. `application/src/Ashlar.API/appsettings.Testing.json` sets it to `true` and the API, quickstart and fleet-host images all ship that file, so `ASPNETCORE_ENVIRONMENT=Testing` serves Swagger too (`docs/DEPLOYMENT.md`) | `true` in `Development`, else `false` | `GET /health` (liveness, constant 200) and `GET /ready` (readiness: 503 while the host is starting or shutting down, 200 in between) are always mapped, unauthenticated, and outside `/api`. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index fd11d7f83..6c2274c85 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -51,7 +51,7 @@ The API images (`.docker/Dockerfile.api`, `Dockerfile.quickstart`, `Dockerfile.f - **`HEALTHCHECK`** probes `GET /health` with bash's `/dev/tcp` (the `mcr.microsoft.com/dotnet/aspnet` runtime images ship no `curl`), so `docker ps` shows `(healthy)` and `docker compose up --wait` returns as soon as the API answers. `scripts/prod-dry-run.sh` no longer hides `--wait` failures. - **`GET /health`** is liveness (constant 200 while the process serves HTTP). **`GET /ready`** is readiness: 200 once the host has finished starting (DI built, hosted services started) and 503 while starting or once shutdown begins (`IHostApplicationLifetime`), so orchestrators drain traffic before Kestrel closes. Both are unauthenticated and outside `/api`. Use `/ready` for Kubernetes `readinessProbe` / load-balancer checks and `/health` for `livenessProbe`; the k8s sample wires both. - **Non-root:** the runtime stages run as the aspnet image's unprivileged `app` user (`USER $APP_UID`, uid 1654 in .NET 8+). `/app` is root-owned; the process writes under `/data` (owned by `app`), plus two writable, non-persistent scratch dirs the runtime creates on demand - `/app/.ashlar` (cycle telemetry, taxonomy) and `/app/config` (trust-pack registry) - which the images pre-create and chown. Named volumes created by **older, root-running images** keep root ownership — fix once with `docker run --rm -v :/v alpine chown -R 1654:1654 /v` (e.g. `ashlar-dailies`) or recreate the volume. -- **Swagger** (`/swagger`, `/swagger/v1/swagger.json`) is on only in the `Development` environment or when `Ashlar__Api__EnableSwagger=true`; production images ship with it off. +- **Swagger** (`/swagger`, `/swagger/v1/swagger.json`) is on in the `Development` environment or when `Ashlar__Api__EnableSwagger=true`, and otherwise off; the images set no `ASPNETCORE_ENVIRONMENT`, so unless a deployment sets one they run as `Production` with it off. `Dockerfile.fleet-host` honours the same key (it used to serve Swagger in every environment). One exception: `ASPNETCORE_ENVIRONMENT=Testing` also turns it on in all three images, because `application/src/Ashlar.API/appsettings.Testing.json` sets the key and every image ships that file (the fleet-host image through its ProjectReference to Ashlar.API). Do not run an image in `Testing`. - **Kubernetes:** `ASHLAR_DEPLOYMENT_PROFILE` must be one of `full`, `server`, `edge`, `air-gapped`, `secure-workstation` (alias `workstation`), `system`. Underscores and collapsed forms also parse (`air_gapped`, `secure_workstation`). `AddAshlar` refuses anything else at startup. `air-gapped` is **not** the IDE workstation profile — use `secure-workstation` or `products/ashlar-workstation` `AddAshlarWorkstation()` (which also enables trust). The mesh-worker sample uses `server`. ## Runtime state (`ASHLAR_STATE_DIR`) diff --git a/docs/EnvironmentSetupGate-v1.md b/docs/EnvironmentSetupGate-v1.md index 9ac1dd8fd..d6d4bf209 100644 --- a/docs/EnvironmentSetupGate-v1.md +++ b/docs/EnvironmentSetupGate-v1.md @@ -14,6 +14,16 @@ Fail fast on environment drift before deeper functional gates run. - Ephemeral Linux container: - `mcr.microsoft.com/dotnet/sdk:10.0` +The three host labels are the ones `.github/workflows/environment-setup-gate-v1.yml` uses, and they +float: they name whatever image GitHub points them at, not an OS version. On 2026-09-30 they resolved +to Ubuntu 24.04 (`ubuntu-24.04`), macOS 26 on arm64 (`macos-26-arm64`) and Windows Server 2025 with +Visual Studio 2026 (`windows-2025-vs2026`), read from the "Set up job" step of this repository's runs. +From 2026-10-19 GitHub starts moving `ubuntu-latest` to Ubuntu 26.04 (actions/runner-images#14748). +This gate is advisory and not pinned yet. The required readiness lanes that cover the same three +platforms (`full-platform-readiness-gate.yml`, "Readiness summary") are pinned to `ubuntu-24.04`, +`macos-26` and `windows-2025-vs2026`, and `scripts/ci/verify-required-runner-pins.py` refuses a +floating label in any required job. + Ephemeral container validation (Linux): - `mcr.microsoft.com/dotnet/sdk:10.0` and `mcr.microsoft.com/dotnet/sdk:10.0-noble` diff --git a/docs/ExecutionPlan.md b/docs/ExecutionPlan.md index 046a5eab4..ef0a67f0a 100644 --- a/docs/ExecutionPlan.md +++ b/docs/ExecutionPlan.md @@ -32,8 +32,8 @@ Snapshot of this plan versus the repo today. **Implemented** = code exists; **Te | C.3 CI `continue-on-error` (test caching workflow) | Implemented | Docker build/test steps are gating; summary job fails on `test-caching` failure. | | C.4 PR / Issue Templates | Implemented | `.github/PULL_REQUEST_TEMPLATE.md` and `ISSUE_TEMPLATE/*.md` present. | | C.5 Self-Improvement as Background Agent | Implemented | `self-improver` role in `BackgroundAgentRegistry`. | -| C.6 Trust Wiring in ImproveCommand | Implemented | `ASHLAR_TRUST_ENABLED=1` registers `SanitizingProviderFactory` in improve DI. | -| C.6 (tests) | Remaining work | Add CLI test asserting sanitizing factory when trust + cloud provider configured, if missing. | +| C.6 Trust Wiring in ImproveCommand | Implemented | `ASHLAR_TRUST_ENABLED=1` registers the trust composition (`AddTrustServices`) and `SanitizingProviderFactory` in improve DI, so the proxy has a PII content filter. Before the fail-closed change it was built with none and passed every prompt through. | +| C.6 (tests) | Tested | [ImproveCommandTrustWiringTests.cs](../application/src/Ashlar.Tests.CLI/Tests/Commands/ImproveCommandTrustWiringTests.cs). | --- @@ -584,11 +584,11 @@ These are smaller items that should be addressed opportunistically alongside the ### C.6 Trust Wiring in `ImproveCommand` -**Current state:** **Implemented** — when `ASHLAR_TRUST_ENABLED=1`, `ImproveCommand` registers `SanitizingProviderFactory` wrapping the inner `IProviderFactory`. +**Current state:** **Implemented** — when `ASHLAR_TRUST_ENABLED=1`, `ImproveCommand` registers the kernel's trust composition (`AddTrustServices`) and a `SanitizingProviderFactory` wrapping the inner `IProviderFactory`. Before the fail-closed change (`CHANGELOG.md`, Unreleased) the proxy behind that factory was built with no content filter and passed every prompt through; it now filters PII, and a proxy with no filter blocks. -**Tasks:** *(wiring complete; tests optional)* +**Tasks:** *(complete)* 1. Conditionally register `SanitizingProviderFactory` in `ImproveCommand`'s DI graph when trust is enabled. **Done** (`ASHLAR_TRUST_ENABLED` == `1`). -2. Add a test that validates sanitization is active when improve is configured for cloud-backed fix generation. **Remaining** if not present. +2. Add a test that validates sanitization is active when improve is configured for cloud-backed fix generation. **Done** for the DI graph: `ImproveCommandTrustWiringTests` resolves improve's provider registration and asserts that the proxy passes a clean prompt and blocks one carrying PII. No test drives a cloud provider end to end. **Files:** `application/src/Ashlar.CLI/Commands/ImproveCommand.cs`, `application/src/Ashlar.Tests.CLI/` **Risk:** Low. Defensive wiring. diff --git a/docs/GapAnalysis.md b/docs/GapAnalysis.md index 55950875e..cb225b7d4 100644 --- a/docs/GapAnalysis.md +++ b/docs/GapAnalysis.md @@ -79,10 +79,10 @@ Keep CLI parity marked as resolved; prioritize documentation examples that map e - Default `FixGenerator` paths are rule-based; cloud LLM usage in improve is optional and configuration-dependent. ### Gap -- **Resolved.** When `ASHLAR_TRUST_ENABLED=1`, `ImproveCommand` registers `SanitizingProviderFactory` in its CLI-local service collection so cloud-backed fix generation uses the same sanitization path as hosting when Trust is on. +- **Resolved.** When `ASHLAR_TRUST_ENABLED=1`, `ImproveCommand` registers the kernel's trust composition (`AddTrustServices`, which supplies the PII content filter, the taxonomy and the sanitization audit log) in its CLI-local service collection and wraps `ProviderFactory` in `SanitizingProviderFactory`, so cloud-backed fix generation uses the same sanitization path as hosting when Trust is on. Until the fail-closed change (`CHANGELOG.md`, Unreleased) the wiring had no effect: improve registered `CloudSanitizationProxy` by type alone, DI built it with no content filter, and the proxy passed every prompt through. A proxy with no filter now blocks every non-air-gapped prompt instead. ### Recommendation -- Add or extend a CLI test that asserts the improve DI graph resolves a sanitizing factory when `ASHLAR_TRUST_ENABLED=1` and a cloud provider is configured (if not already present). +- **Done.** `application/src/Ashlar.Tests.CLI/Tests/Commands/ImproveCommandTrustWiringTests.cs` builds the provider registration improve uses. With trust enabled, both provider ports resolve to the one `SanitizingProviderFactory` and its proxy has a content filter (a clean prompt passes; a prompt carrying an email address is blocked for PII). With trust disabled, no sanitizer is registered. The test resolves the registration directly; it does not configure a cloud provider. --- diff --git a/docs/ProductionReadinessGate-v1.md b/docs/ProductionReadinessGate-v1.md index 2b3f77639..d11c4deca 100644 --- a/docs/ProductionReadinessGate-v1.md +++ b/docs/ProductionReadinessGate-v1.md @@ -215,4 +215,11 @@ Run `environment-setup-gate-v1` in GitHub Actions to validate dependency bootstr - `macos-latest`: `scripts/setup/setup.sh check` + `scripts/setup/setup.sh restore` - `windows-latest`: `scripts/setup/setup.ps1 -Mode check` + `scripts/setup/setup.ps1 -Mode restore` +Those three are floating labels, and `environment-setup-gate-v1` (advisory) still uses them, so a green +run says which image it ran on only in its "Set up job" step. On 2026-09-30 the three labels resolved to +`ubuntu-24.04`, `macos-26-arm64` and `windows-2025-vs2026`; from 2026-10-19 GitHub starts moving +`ubuntu-latest` to Ubuntu 26.04 (actions/runner-images#14748). The required readiness lanes +(`full-platform-readiness-gate.yml`) are pinned to `ubuntu-24.04`, `macos-26` and +`windows-2025-vs2026` instead. + This gate validates the repo can be prepared cleanly on each OS before functional/runtime gates run. diff --git a/docs/ReleaseCandidateChecklist-v1.md b/docs/ReleaseCandidateChecklist-v1.md index 2f53a0e9d..c1015cb6a 100644 --- a/docs/ReleaseCandidateChecklist-v1.md +++ b/docs/ReleaseCandidateChecklist-v1.md @@ -17,6 +17,12 @@ Use this checklist to move from "locally passing" to "release-ready with evidenc - [ ] ubuntu-latest - [ ] windows-latest - [ ] macos-latest + + These matrices (`production-readiness-gate-v1`, `environment-setup-gate-v1`) still use floating + `-latest` labels, so the label alone does not say what was tested: record the `Image:` line of each + job's "Set up job" step next to its tick. On 2026-09-30 they resolved to `ubuntu-24.04`, + `windows-2025-vs2026` and `macos-26-arm64`; from 2026-10-19 GitHub starts moving `ubuntu-latest` to + Ubuntu 26.04 (actions/runner-images#14748). - [ ] Confirm uploaded artifacts include: - [ ] test TRX files - [ ] `gate-validate.log` diff --git a/docs/SdkCompatibilityPolicy.md b/docs/SdkCompatibilityPolicy.md index 3521919e4..4564a7ae3 100644 --- a/docs/SdkCompatibilityPolicy.md +++ b/docs/SdkCompatibilityPolicy.md @@ -43,7 +43,7 @@ The analyzers' RS0036 (missing nullable annotation in the API file) and RS0037 ( #### Release step: promote Unshipped -> Shipped on tag -**This has happened.** `v0.1.0`, `v0.1.1` and `v0.1.2` are tagged and published, and the promotion ran: the reviewed surface now lives in `PublicAPI.Shipped.txt` (399 lines in `Ashlar.Abstractions`, 465 in `Ashlar.Brick.Contracts`, 31 in `Ashlar.Client`, 9 in `Ashlar.Sdk`, 5 in `Ashlar.Authoring`) and every `PublicAPI.Unshipped.txt` is back to the bare `#nullable enable` header. +**This has happened.** `v0.1.0`, `v0.1.1` and `v0.1.2` are tagged and published, and the promotion ran: the reviewed surface now lives in `PublicAPI.Shipped.txt` (405 lines in `Ashlar.Abstractions`, 612 in `Ashlar.Brick.Contracts`, 31 in `Ashlar.Client`, 9 in `Ashlar.Sdk`, 5 in `Ashlar.Authoring`), and the promotion left every `PublicAPI.Unshipped.txt` at the bare `#nullable enable` header. Of `Ashlar.Brick.Contracts`' 612, 147 are the generative-profile ports (see "Code-brick authoring surface"), recorded straight into `Shipped.txt` because those types had shipped, untracked, since `v0.1.0`. The `0.2.0` release commit ran the promotion again, moving the six `Ashlar.Abstractions.Paths.PathContainment` lines into `Shipped.txt`, so every `Unshipped.txt` is back at the bare header. So `Shipped.txt` is now a **promise already made**, not an empty file waiting for one. Read the paragraph above accordingly: additions go to `Unshipped.txt` and are promised at the next tag; a change to a line already in `Shipped.txt` is a break against published packages and needs the process in "Breaking change process" below. @@ -75,6 +75,8 @@ The `ashlar new brick` code-brick path references `Ashlar.Authoring` and exposes - `Ashlar.Core.Domain.Bricks.ImplementationType` - `Ashlar.Core.Domain.Execution.IExecutionContext` +`Ashlar.Brick.Contracts` also ships the generative-profile ports in `Ashlar.Core.Domain.Bricks.Ports`: `AgentProfile`, `AgentProfileCapabilities`, `GenerationTunables`, `BrickConstraintManifest`, `GenerationRequest`, `GeneratedArtifact`, `IArtifactDrafter`, `IDeterministicDrafter`, `ISandboxProvider`, `IDeploymentTarget`, `DeploymentApplyResult`, `IAcceptanceEvaluator`, `IAcceptanceGatedDeploymentTarget`, `DefaultAcceptanceEvaluator`, `AcceptanceDecision`, `AcceptanceContext`, `AcceptanceResult`, `DeploymentSmokeResult`. They have been public, unchanged, since `v0.1.0` and are stable-tier. A file-wide `#pragma warning disable RS0016` in each of their five source files used to keep them out of public-API tracking, so nothing enforced that promise; they are now in `PublicAPI.Shipped.txt`. In this repository, `DiagnosticSuppressionConventionTests` (cert-gate) fails any suppression of RS0016, RS0017, RS0026 or RS0027, and any CS0618 or ASHLAREXP001 disable that does not name the symbol it is for with a reason, other than two reviewed project-wide opt-ins and eight grandfathered pragmas. The check is lexical; its row in `ci/cert-gate-assertions.md` lists what it does not see. + ### Experimental APIs are marked with the .NET [`Experimental`](https://learn.microsoft.com/dotnet/api/system.diagnostics.codeanalysis.experimentalattribute) attribute. They may change or be removed in any release, MINOR or PATCH, without a deprecation window. Consumers should treat them as preview-only and pin package versions if they take a dependency. diff --git a/docs/knowledge-graph.json b/docs/knowledge-graph.json index 73cf50c11..a83fdd50b 100644 --- a/docs/knowledge-graph.json +++ b/docs/knowledge-graph.json @@ -15,8 +15,8 @@ "operator_facing_config_variables": 153, "operator_vars_missing_from_configuration_reference": 18, "operator_vars_absent_from_all_documentation": 0, - "test_files_declaring_facts": 733, - "declared_facts": 4403, + "test_files_declaring_facts": 743, + "declared_facts": 4495, "workflows": 62 }, "operator_vars_missing_from_configuration_reference": [ @@ -2705,6 +2705,13 @@ "declared_facts": 1, "in_cert_gate_filter": false }, + { + "file": "application/src/Ashlar.Tests.CLI/Tests/Commands/ImproveCommandTrustWiringTests.cs", + "project": "Ashlar.Tests.CLI", + "namespace": "Ashlar.Tests.CLI.Tests.Commands", + "declared_facts": 3, + "in_cert_gate_filter": false + }, { "file": "application/src/Ashlar.Tests.CLI/Tests/Commands/InitCommandTests.cs", "project": "Ashlar.Tests.CLI", @@ -2789,6 +2796,13 @@ "declared_facts": 10, "in_cert_gate_filter": false }, + { + "file": "application/src/Ashlar.Tests.CLI/Tests/Commands/RAGCommandFailClosedTests.cs", + "project": "Ashlar.Tests.CLI", + "namespace": "Ashlar.Tests.CLI.Tests.Commands", + "declared_facts": 6, + "in_cert_gate_filter": false + }, { "file": "application/src/Ashlar.Tests.CLI/Tests/Commands/ReportBackgroundAgentCommandTests.cs", "project": "Ashlar.Tests.CLI", @@ -3069,6 +3083,20 @@ "declared_facts": 5, "in_cert_gate_filter": false }, + { + "file": "src/Ashlar.Tests.AI.Pipeline/TrustTierOrderTests.cs", + "project": "Ashlar.Tests.AI.Pipeline", + "namespace": "Ashlar.Tests.AI.Pipeline", + "declared_facts": 6, + "in_cert_gate_filter": false + }, + { + "file": "src/Ashlar.Tests.AI.Pipeline/VectorDataRagFailClosedTests.cs", + "project": "Ashlar.Tests.AI.Pipeline", + "namespace": "Ashlar.Tests.AI.Pipeline", + "declared_facts": 8, + "in_cert_gate_filter": false + }, { "file": "src/Ashlar.Tests.AI.Pipeline/VectorDataRagTests.cs", "project": "Ashlar.Tests.AI.Pipeline", @@ -3416,7 +3444,7 @@ "file": "src/Ashlar.Tests.BackgroundAgents/Configuration/BackgroundAgentConfigLoaderGapCoverageTests.cs", "project": "Ashlar.Tests.BackgroundAgents", "namespace": "Ashlar.Tests.BackgroundAgents.Configuration", - "declared_facts": 13, + "declared_facts": 20, "in_cert_gate_filter": false }, { @@ -3458,7 +3486,7 @@ "file": "src/Ashlar.Tests.BackgroundAgents/DataSensitivity/DataSensitivityMarkerTests.cs", "project": "Ashlar.Tests.BackgroundAgents", "namespace": "Ashlar.Tests.BackgroundAgents.DataSensitivity", - "declared_facts": 7, + "declared_facts": 8, "in_cert_gate_filter": false }, { @@ -3622,6 +3650,13 @@ "declared_facts": 2, "in_cert_gate_filter": false }, + { + "file": "src/Ashlar.Tests.BackgroundAgents/RAG/LegacyVectorStoreFailClosedTests.cs", + "project": "Ashlar.Tests.BackgroundAgents", + "namespace": "Ashlar.Tests.BackgroundAgents.RAG", + "declared_facts": 8, + "in_cert_gate_filter": false + }, { "file": "src/Ashlar.Tests.BackgroundAgents/RAG/RAGServiceTests.cs", "project": "Ashlar.Tests.BackgroundAgents", @@ -3633,7 +3668,7 @@ "file": "src/Ashlar.Tests.BackgroundAgents/RAG/RAGToolTests.cs", "project": "Ashlar.Tests.BackgroundAgents", "namespace": "Ashlar.Tests.BackgroundAgents.RAG", - "declared_facts": 5, + "declared_facts": 6, "in_cert_gate_filter": false }, { @@ -3727,6 +3762,13 @@ "declared_facts": 4, "in_cert_gate_filter": false }, + { + "file": "src/Ashlar.Tests.BackgroundAgents/Security/AgentPolicyNarrowingValidatorTests.cs", + "project": "Ashlar.Tests.BackgroundAgents", + "namespace": "Ashlar.Tests.BackgroundAgents.Security", + "declared_facts": 3, + "in_cert_gate_filter": false + }, { "file": "src/Ashlar.Tests.BackgroundAgents/Security/BackgroundAgentPolicyEngineFactoryGapCoverageTests.cs", "project": "Ashlar.Tests.BackgroundAgents", @@ -3752,7 +3794,7 @@ "file": "src/Ashlar.Tests.BackgroundAgents/Security/DataExfiltrationPolicyTests.cs", "project": "Ashlar.Tests.BackgroundAgents", "namespace": "Ashlar.Tests.BackgroundAgents.Security", - "declared_facts": 7, + "declared_facts": 10, "in_cert_gate_filter": false }, { @@ -3913,7 +3955,7 @@ "file": "src/Ashlar.Tests.BackgroundAgents/Trust/CloudSanitizationProxyTests.cs", "project": "Ashlar.Tests.BackgroundAgents", "namespace": "Ashlar.Tests.BackgroundAgents.Trust", - "declared_facts": 5, + "declared_facts": 7, "in_cert_gate_filter": false }, { @@ -4994,6 +5036,13 @@ "declared_facts": 4, "in_cert_gate_filter": true }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/DiagnosticSuppressionConventionTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", + "declared_facts": 15, + "in_cert_gate_filter": true + }, { "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EvidenceArchiveCompositionConventionTests.cs", "project": "Ashlar.Tests.Infrastructure", @@ -5117,7 +5166,7 @@ "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/MediatedWritePathTests.cs", "project": "Ashlar.Tests.Infrastructure", "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", - "declared_facts": 13, + "declared_facts": 16, "in_cert_gate_filter": true }, { @@ -5155,6 +5204,20 @@ "declared_facts": 1, "in_cert_gate_filter": true }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/PathContainmentConventionTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", + "declared_facts": 5, + "in_cert_gate_filter": true + }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/PathContainmentTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", + "declared_facts": 16, + "in_cert_gate_filter": true + }, { "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/PinnedHotSwapVerifyAtLoadTests.cs", "project": "Ashlar.Tests.Infrastructure", @@ -5876,6 +5939,13 @@ "declared_facts": 4, "in_cert_gate_filter": false }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Hosting/MeaiVectorDataRagAdapterTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Hosting", + "declared_facts": 3, + "in_cert_gate_filter": false + }, { "file": "src/Ashlar.Tests.Infrastructure/Tests/Hosting/OpenTelemetryTests.cs", "project": "Ashlar.Tests.Infrastructure", @@ -7175,7 +7245,7 @@ "file": "src/Ashlar.Tests.Kernel/OutputPathSandboxedTests.cs", "project": "Ashlar.Tests.Kernel", "namespace": "Ashlar.Tests.Kernel", - "declared_facts": 8, + "declared_facts": 10, "in_cert_gate_filter": false }, { diff --git a/docs/knowledge-graph.md b/docs/knowledge-graph.md index 2a0d97a68..9ece4ad20 100644 --- a/docs/knowledge-graph.md +++ b/docs/knowledge-graph.md @@ -19,8 +19,8 @@ remembered to update — staleness here is a red build. | of those, operator-facing (not test-only) | 153 | | missing from `docs/Configuration.md` | 18 | | **mentioned in no document at all** | **0** | -| test files declaring xUnit facts | 733 | -| declared facts | 4403 | +| test files declaring xUnit facts | 743 | +| declared facts | 4495 | | workflows | 62 | ## Namespaces cert-gate selects diff --git a/docs/meai-migration-notes.md b/docs/meai-migration-notes.md index 299b5775d..2b657cd80 100644 --- a/docs/meai-migration-notes.md +++ b/docs/meai-migration-notes.md @@ -110,7 +110,7 @@ Agents / ToolCallingAgent / Orchestration | `DataExfiltrationPolicy` | `src/Ashlar.BackgroundAgents/Security/DataExfiltrationPolicy.cs` | Tool-call policy: blocks LLM/search when sensitivity forbids | | `SupportDiagnosticsExporter` | `application/src/Ashlar.API/Security/` | Redacts sensitive **config** keys (not LLM prompts) | -**Behavior today (CloudSanitizationProxy):** air-gapped → pass-through; else PII detected → **block**; filterable PII → **redact**; taxonomy may further constrain. Not yet policy-pack-driven per destination target (Phase 2 must make this policy-driven: redact / block / pass by target). +**Behavior today (CloudSanitizationProxy):** air-gapped → pass-through; else no content filter configured → **block**; PII detected → **block**; filterable PII → **redact**. The taxonomy is accepted but not consulted. Not yet policy-pack-driven per destination target (Phase 2 must make this policy-driven: redact / block / pass by target). **Noise:** `SanitizeXmlName`, `SanitizeIdentifier`, Unity `SanitizeClassName` — unrelated to LLM egress. diff --git a/scripts/ci/verify-required-runner-pins.py b/scripts/ci/verify-required-runner-pins.py new file mode 100644 index 000000000..626f31338 --- /dev/null +++ b/scripts/ci/verify-required-runner-pins.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Every job behind a REQUIRED status check runs on a pinned runner image, never a floating label. + +WHY. A `-latest` runner label is not a version; it is a pointer GitHub moves. From 2026-10-19 to +2026-11-19 `ubuntu-latest` moves from Ubuntu 24.04 to Ubuntu 26.04 (actions/runner-images#14748), +and with it the tools under this repository's required checks: shellcheck 0.9.0 -> 0.11.0 (the +linter shell-lint runs), Python 3.12.3 -> 3.14.4 (every repo gate), Docker Compose 2.38.2 -> 5.1.3. +None of that is a commit here, so none of it is reviewed, and for the month the rollout lasts two +runs of the SAME commit can land on different images. A required check that goes red that way +blocks every pull request in the repository (enforce_admins=true, no bypass) with a diff that +explains nothing. Pinning makes an image upgrade a pull request someone reads. + +SCOPE. The five workflows whose jobs sit behind master's required status checks (REQUIRED below, +checked against `gh api repos/IanFrelinger/Ashlar/branches/master/protection/required_status_checks` +on 2026-09-30). EVERY job in those files is judged, not only the one that owns the required context: +"Readiness summary" `needs:` every readiness lane, so a lane on a moving image moves the required +verdict with it. Other workflows are advisory and out of scope here. + +WHAT IS JUDGED. Each job's `runs-on`. A `${{ matrix. }}` reference is resolved to every value +the job's `strategy.matrix` gives that key, at matrix level and in each `include:` entry, and each +value is judged where it is written. A list or a `labels:` mapping is judged label by label. + +PINNED means a label that names one image family, in one of these shapes: + ubuntu-NN.NN[-arm] e.g. ubuntu-24.04 + macos-NN[-intel|-large|-xlarge] e.g. macos-26 + windows-NNNN-vsNNNN e.g. windows-2025-vs2026 +Everything else is refused, and the refusal says why: + - anything containing `latest` (any case; runner labels are case-insensitive); + - `windows-NNNN` alone. It names the Windows Server release but not the Visual Studio image, and + GitHub has moved it between them: `windows-2025` was the VS2022 image until June 2026 and is the + VS2026 one since (actions/runner-images#14017, rolled out 2026-06-08 to 2026-06-15; #14004 saw + some jobs moved from 2026-05-05; the README only caught up on 2026-07-06, 69bb04343d98); + - an expression this check cannot evaluate (`${{ inputs.x }}`, `${{ fromJSON(...) }}`, a matrix + key the matrix does not define, a matrix built from an expression); + - any other label (self-hosted, a larger-runner group, `ubuntu-slim`): unknown is not pinned. +A pinned label still receives the weekly image rebuild within its family (runs of 2026-09-30 on +`ubuntu-latest` reported both ubuntu-24.04 20260920.314.1 and 20260927.320.1). GitHub-hosted runners +offer no way to pin an image build; the family is what can be pinned, and it is what moves under -latest. + +THE ONLY EXCEPTION is a YAML comment on the line of the value itself, in the workflow file: + runs-on: ubuntu-slim # runner-pin: allow + - os: macos-latest # runner-pin: allow +The reason is required. There is no central baseline: an exception lives, and is reviewed, next to +the label it excuses. An exception on a line whose labels are all pinned is refused as stale, and a +`runner-pin:` comment on any other line of these files is refused as stray, because it excuses nothing +while looking as though it does. On a flow list (`os: [a, b]`) one comment covers the whole line. For +`runs-on: ${{ matrix.os }}` the values are judged, so the comment goes on the matrix value's line. +It must be a COMMENT: the same text inside a value (a quoted string such as `label: "x # runner-pin: +allow ..."`, or a `run: |` script) is data, excuses nothing, and is refused wherever it appears. +PyYAML keeps no comments, so a comment is found by elimination (comment_starts below): the first `#` +that follows whitespace or starts the line and lies outside every scalar the YAML scanner reads. + +NON-VACUITY. A scanner that stops matching finds nothing and reports clean. So: each of the five +files must exist and parse, and must still contain the job whose name is the required context; the +real tree must yield at least MINIMUM_LABELS judged labels and at least one of each OS family in +REQUIRED_FAMILIES (the readiness matrix is the only place macOS and Windows appear, so losing the +matrix resolution fails here rather than passing having judged two fewer labels). + +What it does NOT check: + - that a pin equals what its -latest label resolved to. That is a fact about GitHub on a date, not + about this tree; it was read from the "Set up job" step of real runs when the pins were set; + - tools a job installs itself (setup-dotnet `10.0.x`, setup-python, `apt-get install`), or + container images a lane pulls or builds (`mcr.microsoft.com/dotnet/sdk:10.0` is also a tag that + moves). A pinned runner does not pin those; + - the runner of a reusable workflow a job calls (such a job is refused, since its runner is not + visible here), or of anything outside the five files; + - any workflow outside REQUIRED. + +Run: python3 scripts/ci/verify-required-runner-pins.py # the repository (all checks) + python3 scripts/ci/verify-required-runner-pins.py --root DIR # a fixture tree (no floor, no + # required OS families) +""" +import os +import re +import sys + +try: + import yaml +except ImportError: # pragma: no cover - shell-lint installs PyYAML before the repo gates run + print("required-runner-pins: ERROR: PyYAML is not installed (pip install pyyaml); refusing to " + "report clean without having read a workflow", file=sys.stderr) + raise SystemExit(2) + +PREFIX = "required-runner-pins" + +# Workflow file -> the required status-check context its jobs report. Branch protection requires +# exactly these five (2026-09-30). The context must still be a job name in the file, or the scan has +# lost what it is for. +REQUIRED = [ + (".github/workflows/cert-gate.yml", "cert-gate"), + (".github/workflows/build-gate.yml", "build-core"), + (".github/workflows/shell-lint.yml", "shell-lint"), + (".github/workflows/docs-link-check.yml", "lychee (README + docs)"), + (".github/workflows/full-platform-readiness-gate.yml", "Readiness summary"), +] + +# Judged labels on the real tree when this was written: 12 (one job each in four files; in the +# readiness gate `changes`, three native lanes, the container, docker-cli, docker-all-images and +# summary jobs). A literal on purpose, and set under that count so removing one lane is not a red +# build - but losing a file or the matrix resolution is. +MINIMUM_LABELS = 10 +REQUIRED_FAMILIES = ("ubuntu", "macos", "windows") + +PINNED = [ + re.compile(r"ubuntu-\d{2}\.\d{2}(-arm)?"), + re.compile(r"macos-\d{2}(-intel|-large|-xlarge)?"), + re.compile(r"windows-\d{4}-vs\d{4}"), +] +WINDOWS_OS_ONLY = re.compile(r"windows-\d{4}") +MATRIX_REF = re.compile(r"\$\{\{\s*matrix\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}") +MARKER = re.compile(r"#\s*runner-pin:") +EXCEPTION = re.compile(r"#\s*runner-pin:\s*allow\b(.*)$") + + +def classify(label): + """(verdict, reason) for one literal label. verdict is 'pinned' or 'refused'.""" + low = label.strip().lower() + if "${{" in low: + return "refused", "is an expression this check cannot evaluate statically" + if "latest" in low: + return "refused", ("is a floating -latest label: GitHub moves it to a newer image without a commit " + "here (ubuntu-latest, for one, moves to ubuntu-26.04 from 2026-10-19)") + if WINDOWS_OS_ONLY.fullmatch(low): + return "refused", ("names the Windows Server release but not the Visual Studio image, and GitHub has " + "moved it between images (windows-2025: VS2022 until June 2026, VS2026 since, " + "actions/runner-images#14017); pin " + "windows-NNNN-vsNNNN") + if any(rx.fullmatch(low) for rx in PINNED): + return "pinned", "" + return "refused", ("is not a label this check knows to be pinned (ubuntu-NN.NN[-arm], " + "macos-NN[-intel|-large|-xlarge], windows-NNNN-vsNNNN)") + + +def comment_starts(text, lines): + """{line1: column0} where each line's YAML comment starts, for the lines that have one. + + PyYAML drops comments, so they are found by elimination. A `#` starts a comment only when it + follows whitespace or starts the line AND lies outside every scalar the YAML scanner reads: a + quoted string (`label: "a # b"`), a plain value (`C#`), or a block scalar's content (a `run: |` + script). A block scalar's header line holds only its indicator, so a comment after `|` counts. + """ + spans = {} # line0 -> [(start, end)], end None = to the end of the line + for tok in yaml.scan(text, Loader=yaml.SafeLoader): + if not isinstance(tok, yaml.ScalarToken): + continue + s, e = tok.start_mark, tok.end_mark + first = s.line + 1 if tok.style in ("|", ">") else s.line + for ln in range(first, e.line + 1): + spans.setdefault(ln, []).append((s.column if ln == s.line else 0, e.column if ln == e.line else None)) + starts = {} + for i, raw in enumerate(lines): + for c, ch in enumerate(raw): + if ch != "#" or (c and raw[c - 1] not in " \t"): + continue + if any(a <= c and (b is None or c < b) for a, b in spans.get(i, ())): + continue + starts[i + 1] = c + break + return starts + + +def mapping_get(node, key): + if isinstance(node, yaml.MappingNode): + for k, v in node.value: + if isinstance(k, yaml.ScalarNode) and k.value == key: + return v + return None + + +class Scan: + def __init__(self, root): + self.root = root + self.problems = [] + self.sites = [] # (rel, line1, job, label, verdict) after exceptions are applied + self.pending = [] # sites of the file being scanned, before its exceptions are applied + self.consumed = set() # (rel, line1) whose runner-pin comment sits on a judged value + self.jobs = 0 + self.files = 0 + + def problem(self, msg): + self.problems.append(msg) + + def judge_label(self, job, node, label): + """Record one literal label where it is written. Exceptions are settled per line in settle().""" + verdict, reason = classify(label) + self.pending.append({"line": node.start_mark.line + 1, "job": job, "label": label, + "verdict": verdict, "reason": reason}) + + def judge(self, job, node, matrix): + """Judge a runs-on value, or one part of one.""" + if isinstance(node, yaml.SequenceNode): + if not node.value: + self.judge_label(job, node, "") + for item in node.value: + self.judge(job, item, matrix) + return + if isinstance(node, yaml.MappingNode): + labels = mapping_get(node, "labels") + if labels is None: + self.judge_label(job, node, "") + return + self.judge(job, labels, matrix) + return + value = node.value if isinstance(node, yaml.ScalarNode) else "" + m = MATRIX_REF.fullmatch(value.strip()) + if not m: + self.judge_label(job, node, value) + return + key = m.group(1) + if matrix is None: + self.judge_label(job, node, value + " (the job has no strategy.matrix)") + return + if not isinstance(matrix, yaml.MappingNode): + self.judge_label(job, node, value + " (strategy.matrix is an expression)") + return + values = [] + direct = mapping_get(matrix, key) + if direct is not None: + values.append(direct) + include = mapping_get(matrix, "include") + if isinstance(include, yaml.SequenceNode): + for entry in include.value: + v = mapping_get(entry, key) + if v is not None: + values.append(v) + elif include is not None: + self.judge_label(job, include, "${{ ... }} (strategy.matrix.include is an expression)") + if not values: + self.judge_label(job, node, value + f" (the matrix defines no '{key}')") + return + for v in values: + # A matrix value is a label or a list of them; a nested matrix reference cannot be resolved. + self.judge(job, v, None) + + def settle(self, rel, lines, comments): + """Apply `# runner-pin: allow` comments line by line, then record every site of this file.""" + by_line = {} + for site in self.pending: + by_line.setdefault(site["line"], []).append(site) + self.pending = [] + for line1 in sorted(by_line): + group = by_line[line1] + raw = lines[line1 - 1] if line1 - 1 < len(lines) else "" + # Only the line's YAML comment can grant an exception, never text inside a value on it. + exc = EXCEPTION.search(raw[comments[line1]:]) if line1 in comments else None + where = f"{rel}:{line1}" + if exc: + self.consumed.add(line1) + why = exc.group(1).strip() + if all(g["verdict"] == "pinned" for g in group): + shown = ", ".join(f"'{g['label']}'" for g in group) + self.problem(f"{where}: job '{group[0]['job']}': stale exception - {shown} is already " + "pinned, so `# runner-pin: allow` excuses nothing; remove the comment") + elif not re.search(r"\w{3,}", why): + for g in group: + if g["verdict"] == "refused": + self.problem(f"{where}: job '{g['job']}' runs on '{g['label']}', which {g['reason']}; " + "its `# runner-pin: allow` comment gives no reason, and an exception " + "without one is refused") + else: + for g in group: + if g["verdict"] == "refused": + g["verdict"] = "excepted" + else: + for g in group: + if g["verdict"] == "refused": + self.problem(f"{where}: job '{g['job']}' runs on '{g['label']}', which {g['reason']}. Pin " + "it, or put `# runner-pin: allow ` on this line") + for g in group: + self.sites.append((rel, line1, g["job"], g["label"], g["verdict"])) + + def scan_file(self, rel, context): + path = os.path.join(self.root, rel) + if not os.path.isfile(path): + self.problem(f"{rel}: missing. It carries the required check '{context}'; if it moved, move this " + "check's REQUIRED entry with it") + return + with open(path, encoding="utf-8") as fh: + text = fh.read() + lines = text.split("\n") + try: + doc = yaml.compose(text, Loader=yaml.SafeLoader) + except yaml.YAMLError as e: + self.problem(f"{rel}: does not parse as YAML: {e}") + return + jobs = mapping_get(doc, "jobs") + if not isinstance(jobs, yaml.MappingNode) or not jobs.value: + self.problem(f"{rel}: no jobs found, so nothing here was judged") + return + self.files += 1 + self.consumed = set() + names = [] + for k, job in jobs.value: + jid = k.value + self.jobs += 1 + name_node = mapping_get(job, "name") + names.append(name_node.value if isinstance(name_node, yaml.ScalarNode) else jid) + runs_on = mapping_get(job, "runs-on") + if runs_on is None: + uses = mapping_get(job, "uses") + if uses is not None: + self.judge_label(jid, uses, "") + else: + self.judge_label(jid, k, "") + continue + strategy = mapping_get(job, "strategy") + matrix = mapping_get(strategy, "matrix") if strategy is not None else None + self.judge(jid, runs_on, matrix) + comments = comment_starts(text, lines) + self.settle(rel, lines, comments) + if context not in names: + self.problem(f"{rel}: no job is named '{context}' (found: {', '.join(names)}). That is the required " + "check this file is in scope for; if it was renamed, update REQUIRED here and branch " + "protection together") + # runner-pin text inside a value, or a runner-pin comment that sits on no judged value, excuses + # nothing; say so rather than ignore it. + for i, raw in enumerate(lines, start=1): + col = comments.get(i) + if any(col is None or m.start() < col for m in MARKER.finditer(raw)): + self.problem(f"{rel}:{i}: `runner-pin:` text inside a YAML value (a quoted string, or a block " + "scalar such as a run: script) is not a comment, so it excuses nothing; the only " + "exception is a `# runner-pin: allow ` comment after the label on its own line") + comment = raw[col:] if col is not None else "" + if MARKER.search(comment) and i not in self.consumed: + if EXCEPTION.search(comment): + self.problem(f"{rel}:{i}: stray `# runner-pin: allow` comment - it is not on a runs-on or " + "matrix value this check judges, so it excuses nothing; move it onto the label's " + "own line or remove it") + else: + self.problem(f"{rel}:{i}: malformed runner-pin comment; the only form is " + "`# runner-pin: allow ` on the label's own line") + + +def main(argv): + root = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) + strict = True + if len(argv) == 3 and argv[1] == "--root": + root, strict = os.path.abspath(argv[2]), False + elif len(argv) != 1: + print("usage: verify-required-runner-pins.py [--root DIR]", file=sys.stderr) + return 2 + + s = Scan(root) + for rel, context in REQUIRED: + s.scan_file(rel, context) + + if strict: + if len(s.sites) < MINIMUM_LABELS: + s.problem(f"judged {len(s.sites)} runner label(s), expected at least {MINIMUM_LABELS}: the scan has " + "stopped reaching jobs it used to reach, so a clean result would mean nothing. Fix the " + "scan; do not lower the floor") + for fam in REQUIRED_FAMILIES: + if not any(label.strip().lower().startswith(fam + "-") for _, _, _, label, _ in s.sites): + s.problem(f"no {fam} runner was judged. The readiness matrix is where macOS and Windows run; " + "if its lanes are no longer resolved, this check has gone blind to them") + + for rel, line1, job, label, verdict in s.sites: + print(f"{PREFIX}: {rel}:{line1} {job} -> {label} [{verdict}]") + for p in s.problems: + print(f"{PREFIX}: ERROR: {p}") + counts = {v: sum(1 for x in s.sites if x[4] == v) for v in ("pinned", "excepted", "refused")} + print(f"{PREFIX}: {s.files} workflow(s), {s.jobs} job(s), {len(s.sites)} runner label(s): " + f"{counts['pinned']} pinned, {counts['excepted']} excepted, {counts['refused']} refused; " + f"{len(s.problems)} problem(s)") + return 1 if s.problems else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/src/Ashlar.AI.Pipeline/Rag/ChunkRecord.cs b/src/Ashlar.AI.Pipeline/Rag/ChunkRecord.cs index 647f57f9d..9cc3e78f3 100644 --- a/src/Ashlar.AI.Pipeline/Rag/ChunkRecord.cs +++ b/src/Ashlar.AI.Pipeline/Rag/ChunkRecord.cs @@ -21,10 +21,12 @@ public sealed class ChunkRecord /// /// Trust / sensitivity tier tag (e.g. Public, Internal, Confidential, Secret, TopSecret). - /// Must be ≤ caller tier to be returned. + /// Must be ≤ caller tier to be returned. Defaults to the empty string -- UNLABELLED -- which + /// ranks at the top, so a record built without a tier + /// is served only to the top clearance. It used to default to "Public", which published it. /// [VectorStoreData(IsIndexed = true)] - public string TrustTier { get; set; } = "Public"; + public string TrustTier { get; set; } = string.Empty; /// When the chunk was indexed (UTC). [VectorStoreData] diff --git a/src/Ashlar.AI.Pipeline/Rag/TrustTierOrder.cs b/src/Ashlar.AI.Pipeline/Rag/TrustTierOrder.cs index 192e2c110..1185dacea 100644 --- a/src/Ashlar.AI.Pipeline/Rag/TrustTierOrder.cs +++ b/src/Ashlar.AI.Pipeline/Rag/TrustTierOrder.cs @@ -4,8 +4,39 @@ namespace Ashlar.AI.Pipeline.Rag; /// Orders trust/sensitivity tier names for RAG filtering (lower = less sensitive). /// Mirrors Ashlar data-sensitivity primitives without referencing BackgroundAgents. /// +/// +/// A caller and a record fail closed in OPPOSITE directions. A name this table does +/// not know -- blank, misspelt, "Unclassified", or a custom level only the BackgroundAgents +/// registry knows -- tells us nothing, and "nothing" must never widen access: +/// +/// for a CALLER (a clearance) it ranks at the , so an omitted or +/// unrecognised clearance sees only floor data; +/// for a RECORD (a label) it ranks at , so an unlabelled or +/// unrecognised record is visible only to the top clearance. +/// +/// The single Rank this replaces applied one rule to both, and it was wrong +/// in both directions at once: a blank name ranked as Public, so an unlabelled record was served +/// to everyone; an unknown name ranked as TopSecret, so a typo'd caller clearance saw everything. +/// public static class TrustTierOrder { + /// The lowest tier: what an omitted or unrecognised caller clearance is treated as. + public const string Floor = "Public"; + + /// The highest tier: what an unlabelled or unrecognised record is treated as. + public const string MostRestrictive = "TopSecret"; + + /// Audit basis: the caller named a tier this table knows. + public const string BasisExplicit = "explicit"; + + /// Audit basis: the caller named no tier, so the floor was applied. + public const string BasisOmitted = "omitted"; + + /// Audit basis: the caller named a tier this table does not know, so the floor was applied. + public const string BasisUnrecognised = "unrecognised"; + + private static readonly string[] CanonicalNames = { "Public", "Internal", "Confidential", "Secret", "TopSecret" }; + private static readonly Dictionary Ranks = new(StringComparer.OrdinalIgnoreCase) { ["Public"] = 0, @@ -13,20 +44,63 @@ public static class TrustTierOrder ["Confidential"] = 2, ["Secret"] = 3, ["TopSecret"] = 4, + // The spelling Ashlar.BackgroundAgents.DataSensitivity.DataSensitivityLevels.FromName also + // accepts. Without it a clearance the registry validated would floor here. + ["top-secret"] = 4, }; - /// Returns the numeric rank for a tier name (unknown ⇒ TopSecret for fail-closed). - public static int Rank(string? tierName) + /// Returns the rank of a known tier name; false for a blank or unknown one. + public static bool TryRank(string? tierName, out int rank) + { + rank = 0; + return !string.IsNullOrWhiteSpace(tierName) && Ranks.TryGetValue(tierName.Trim(), out rank); + } + + /// Rank of a RECORD label: blank or unknown ranks at . + public static int RecordRank(string? recordTier) => + TryRank(recordTier, out var rank) ? rank : Ranks[MostRestrictive]; + + /// Rank of a CALLER clearance: blank or unknown ranks at . + public static int CallerRank(string? callerTier) => + TryRank(callerTier, out var rank) ? rank : Ranks[Floor]; + + /// + /// The clearance actually applied for , in canonical spelling, and + /// why: , or . + /// + public static (string Applied, string Basis) ResolveCaller(string? callerTier) { - if (string.IsNullOrWhiteSpace(tierName)) + if (string.IsNullOrWhiteSpace(callerTier)) { - return Ranks["Public"]; + return (Floor, BasisOmitted); } - return Ranks.TryGetValue(tierName.Trim(), out var rank) ? rank : Ranks["TopSecret"]; + return TryRank(callerTier, out var rank) + ? (CanonicalNames[rank], BasisExplicit) + : (Floor, BasisUnrecognised); } - /// True when record tier is at or below the caller's max tier. + /// + /// The label to STORE for : a known tier in canonical spelling, an + /// unknown one trimmed but kept (it ranks at ), and a blank one as + /// the empty string -- unlabelled, which also ranks at . No label + /// is invented for a record nobody classified. + /// + public static string NormalizeRecordTier(string? recordTier) + { + if (string.IsNullOrWhiteSpace(recordTier)) + { + return string.Empty; + } + + return TryRank(recordTier, out var rank) ? CanonicalNames[rank] : recordTier.Trim(); + } + + /// True when the record's tier is at or below the caller's max tier. public static bool IsAllowed(string? recordTier, string? callerMaxTier) => - Rank(recordTier) <= Rank(callerMaxTier); + RecordRank(recordTier) <= CallerRank(callerMaxTier); + + /// Rank of a RECORD label. Kept for source compatibility; say which side you mean. + [Obsolete("A caller and a record fail closed in opposite directions. Use RecordRank for a label or CallerRank for a clearance.")] + public static int Rank(string? tierName) => RecordRank(tierName); } diff --git a/src/Ashlar.AI.Pipeline/Rag/VectorDataRagService.cs b/src/Ashlar.AI.Pipeline/Rag/VectorDataRagService.cs index c82d408f0..97e28c673 100644 --- a/src/Ashlar.AI.Pipeline/Rag/VectorDataRagService.cs +++ b/src/Ashlar.AI.Pipeline/Rag/VectorDataRagService.cs @@ -28,13 +28,53 @@ public VectorDataRagService( } /// Indexes a chunk (embed + upsert). + /// + /// An omitted tier is unlabelled, not Public. It is stored as the empty string + /// and ranks it at the top, so it is served only to + /// the top clearance. The parameter used to default to "Public", which published every chunk + /// whose caller forgot to classify it. + /// Re-indexing never lowers a label. Upserting an existing key at a LOWER tier than + /// the one it is stored at is refused (and audited as rag:index / denied): the + /// knowledge-base indexer keys chunks on the full file path, so re-running an index over the + /// same files with a lower label used to downgrade previously Secret content silently. + /// Lowering a label is a separate, explicit act: first. The same or + /// a higher tier is accepted. The read-then-upsert is not atomic across concurrent writers to + /// one key; it guards the sequential re-index, which is the path that downgraded. + /// + /// The key is stored at a higher tier. public async Task IndexAsync( string key, string text, string? sourceUri = null, - string trustTier = "Public", + string? trustTier = null, CancellationToken cancellationToken = default) { + var tier = TrustTierOrder.NormalizeRecordTier(trustTier); + + var existing = await _collection.GetAsync(key, options: null, cancellationToken).ConfigureAwait(false); + if (existing is not null + && TrustTierOrder.RecordRank(tier) < TrustTierOrder.RecordRank(existing.TrustTier)) + { + var stored = Describe(existing.TrustTier); + var requested = Describe(tier); + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = "rag:index", + Outcome = "denied", + PolicyDecisions = new[] + { + "event=downgrade_refused", + $"stored_tier={stored}", + $"requested_tier={requested}", + }, + }); + throw new InvalidOperationException( + $"Refusing to re-index '{key}' at '{requested}': it is already stored at '{stored}', and " + + "re-indexing must never lower a record's sensitivity. If the lower label is correct, " + + "remove the record first and index it again -- lowering a label has to be a separate, " + + "deliberate act."); + } + var embedding = await EmbedAsync(text, cancellationToken).ConfigureAwait(false); var now = DateTimeOffset.UtcNow; await _collection.UpsertAsync(new ChunkRecord @@ -42,7 +82,7 @@ await _collection.UpsertAsync(new ChunkRecord Key = key, Text = text, SourceUri = sourceUri ?? string.Empty, - TrustTier = trustTier, + TrustTier = tier, CreatedAt = now, UpdatedAt = now, Embedding = embedding, @@ -52,18 +92,26 @@ await _collection.UpsertAsync(new ChunkRecord /// /// Searches for similar chunks, excluding records above . /// + /// + /// An omitted or unrecognised is the FLOOR + /// (), never everything. The rag:search audit record + /// carries the clearance actually applied (caller_tier=) and why + /// (caller_tier_basis= explicit | omitted | unrecognised), so a floored search can be + /// told apart from one the caller asked for. + /// public async Task>> SearchAsync( string query, - string callerMaxTrustTier, + string? callerMaxTrustTier, int top = 5, double? minScore = null, CancellationToken cancellationToken = default) { var embedding = await EmbedAsync(query, cancellationToken).ConfigureAwait(false); - var maxRank = TrustTierOrder.Rank(callerMaxTrustTier); + var (appliedTier, basis) = TrustTierOrder.ResolveCaller(callerMaxTrustTier); + var maxRank = TrustTierOrder.CallerRank(appliedTier); var options = new VectorSearchOptions { - Filter = r => TrustTierOrder.Rank(r.TrustTier) <= maxRank, + Filter = r => TrustTierOrder.RecordRank(r.TrustTier) <= maxRank, ScoreThreshold = minScore, }; @@ -82,7 +130,8 @@ public async Task>> SearchAsync( { "event=retrieval", $"results={results.Count}", - $"caller_tier={callerMaxTrustTier}", + $"caller_tier={appliedTier}", + $"caller_tier_basis={basis}", }, }); @@ -131,6 +180,8 @@ public Task GetDocumentCountAsync(CancellationToken cancellationToken = def return Task.FromResult(-1); } + private static string Describe(string? tier) => string.IsNullOrWhiteSpace(tier) ? "(unlabelled)" : tier; + private async Task EmbedAsync(string text, CancellationToken cancellationToken) { var generated = await _embeddings.GenerateAsync([text], cancellationToken: cancellationToken) diff --git a/src/Ashlar.Abstractions/Paths/PathContainment.cs b/src/Ashlar.Abstractions/Paths/PathContainment.cs new file mode 100644 index 000000000..f8dd5992f --- /dev/null +++ b/src/Ashlar.Abstractions/Paths/PathContainment.cs @@ -0,0 +1,217 @@ +using System.Runtime.InteropServices; +using System.Security; + +namespace Ashlar.Abstractions.Paths; + +/// +/// The one lexical answer to "is this path the root, or inside it?". Every containment decision in +/// shipped code calls this instead of re-deriving it: ten hand-written copies had drifted into five +/// different semantics, and one of them (OutputPathSandboxed) compared without a directory +/// separator, so /srv/app/out-evil passed as inside /srv/app/out. +/// PathContainmentConventionTests fails cert-gate on a new hand-written copy it can recognise +/// (its remarks list the shapes it cannot), and PathContainmentTests runs adversarial tables +/// against the call sites themselves. +/// +/// What it does, in order. +/// +/// Refuses (returns ) a null, empty or whitespace root or candidate, +/// and one cannot normalise (invalid characters, an +/// unsupported format, too long). A path that cannot be resolved is never inside anything. +/// Normalises both with : . and .. +/// segments collapse, repeated separators collapse, and on Windows / becomes \ and a +/// trailing dot or space on a segment is dropped, exactly as the OS will resolve it. A RELATIVE +/// input resolves against the process working directory, not against the other argument, so pass +/// absolute paths (combine a relative candidate with the root first). +/// Refuses a normalised path that still has a . or .. segment. Only a Windows +/// \\?\ or \\.\ path survives normalisation with one, and a lexical prefix test on +/// such a path would read \\?\C:\root\..\evil as inside \\?\C:\root. +/// Drops trailing separators (never below the filesystem root, so / and C:\ +/// stay themselves). /srv/app/ and /srv/app are the same directory. +/// Compares separator-aware: the candidate is inside when it equals the root, or starts +/// with the root followed by a directory separator. /srv/app/out-evil is NOT inside +/// /srv/app/out. counts the root itself as inside; +/// does not. +/// +/// +/// Case. The two-argument overloads use : ordinal and +/// case-INSENSITIVE on Windows and macOS, whose default file systems fold case, and ordinal +/// case-SENSITIVE everywhere else. On Linux /srv/App/x is a different directory from +/// /srv/app/x, and treating it as inside is an escape. A caller with a stricter contract +/// passes explicitly. Only the two ordinal comparisons are +/// accepted: a culture-aware comparison can equate strings that name different files. +/// +/// What it deliberately does NOT do. +/// +/// It never touches the filesystem, so it does NOT follow symbolic links, junctions or +/// other reparse points: a link inside the root that points outside it is reported as inside. +/// A caller that will WRITE through the answer must probe for links itself — +/// MediatedWritePath does, after its containment leg. +/// It does not model a case-sensitive volume on Windows or macOS (a case-sensitive APFS +/// volume, an NTFS directory with per-directory case sensitivity on). There, a sibling that +/// differs from the root only by case is reported as inside under +/// ; pass where that +/// matters. +/// It does not unify different spellings of one location: 8.3 short names +/// (PROGRA~1), a \\?\ prefix on one argument but not the other, a mapped drive +/// against its UNC path, or a bind mount. Each such pair compares as unrelated, which refuses +/// rather than admits. +/// +/// +/// Lives in Ashlar.Abstractions because it is the lowest project every copy could +/// reference without a new project reference, and is written to the netstandard2.0 API +/// surface: no Path.GetRelativePath, no OperatingSystem, no EndsWith(char). +/// +public static class PathContainment +{ + private static readonly StringComparison Platform = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + /// on Windows and macOS, + /// on every other platform: the platform's DEFAULT file + /// system case rule, which the two-argument overloads use. + /// + public static StringComparison PlatformComparison => Platform; + + /// + /// True when is itself or lies inside it, + /// compared with . See the type remarks for the exact rules. + /// + /// The path being admitted. Absolute, or relative to the process working directory. + /// The directory it must stay inside. Absolute, or relative to the process working directory. + /// for anything that cannot be normalised. + public static bool IsWithin(string? candidate, string? root) + => Contains(candidate, root, Platform, includeRoot: true); + + /// + /// True when is itself or lies inside it, + /// compared with . + /// + /// The path being admitted. + /// The directory it must stay inside. + /// or . + /// is not one of the two ordinal comparisons. + public static bool IsWithin(string? candidate, string? root, StringComparison comparison) + => Contains(candidate, root, RequireOrdinal(comparison), includeRoot: true); + + /// + /// True when lies inside and is not the root + /// itself, compared with . + /// + /// The path being admitted. + /// The directory it must stay inside. + public static bool IsStrictlyWithin(string? candidate, string? root) + => Contains(candidate, root, Platform, includeRoot: false); + + /// + /// True when lies inside and is not the root + /// itself, compared with . + /// + /// The path being admitted. + /// The directory it must stay inside. + /// or . + /// is not one of the two ordinal comparisons. + public static bool IsStrictlyWithin(string? candidate, string? root, StringComparison comparison) + => Contains(candidate, root, RequireOrdinal(comparison), includeRoot: false); + + private static bool Contains(string? candidate, string? root, StringComparison comparison, bool includeRoot) + { + if (!TryNormalize(root, out var normalizedRoot) || !TryNormalize(candidate, out var normalizedCandidate)) + { + return false; + } + + if (string.Equals(normalizedCandidate, normalizedRoot, comparison)) + { + return includeRoot; + } + + // The separator is the whole fix: without it the root's last segment is a prefix of every + // sibling that extends its name. A filesystem root ("/", "C:\") already ends with one. + var rootWithSeparator = IsSeparator(normalizedRoot[normalizedRoot.Length - 1]) + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; + return normalizedCandidate.StartsWith(rootWithSeparator, comparison); + } + + private static bool TryNormalize(string? path, out string normalized) + { + normalized = string.Empty; + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + string full; + try + { + full = Path.GetFullPath(path!); + } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (PathTooLongException) + { + return false; + } + catch (SecurityException) + { + return false; + } + + if (full.Length == 0 || HasDotSegment(full)) + { + return false; + } + + normalized = TrimTrailingSeparators(full); + return true; + } + + private static bool HasDotSegment(string full) + { + foreach (var segment in full.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + { + if (segment == "." || segment == "..") + { + return true; + } + } + + return false; + } + + private static string TrimTrailingSeparators(string full) + { + var keep = Path.GetPathRoot(full)?.Length ?? 0; + var end = full.Length; + while (end > keep && IsSeparator(full[end - 1])) + { + end--; + } + + return end == full.Length ? full : full.Substring(0, end); + } + + private static bool IsSeparator(char c) + => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + + private static StringComparison RequireOrdinal(StringComparison comparison) + { + if (comparison != StringComparison.Ordinal && comparison != StringComparison.OrdinalIgnoreCase) + { + throw new ArgumentException( + "Path containment compares ordinally; a culture-aware comparison can equate strings that name different files.", + nameof(comparison)); + } + + return comparison; + } +} diff --git a/src/Ashlar.Abstractions/PublicAPI.Shipped.txt b/src/Ashlar.Abstractions/PublicAPI.Shipped.txt index c3ec6f6f4..af3efa827 100644 --- a/src/Ashlar.Abstractions/PublicAPI.Shipped.txt +++ b/src/Ashlar.Abstractions/PublicAPI.Shipped.txt @@ -397,3 +397,9 @@ static readonly Ashlar.Abstractions.Barriers.BarrierAuthoritySource.Default -> s static readonly Ashlar.Abstractions.Barriers.BarrierAuthoritySource.Header -> string! static readonly Ashlar.Abstractions.Barriers.BarrierAuthoritySource.JwtClaim -> string! static readonly Ashlar.Abstractions.Barriers.BarrierAuthoritySource.PkiCertificate -> string! +Ashlar.Abstractions.Paths.PathContainment +static Ashlar.Abstractions.Paths.PathContainment.IsStrictlyWithin(string? candidate, string? root) -> bool +static Ashlar.Abstractions.Paths.PathContainment.IsStrictlyWithin(string? candidate, string? root, System.StringComparison comparison) -> bool +static Ashlar.Abstractions.Paths.PathContainment.IsWithin(string? candidate, string? root) -> bool +static Ashlar.Abstractions.Paths.PathContainment.IsWithin(string? candidate, string? root, System.StringComparison comparison) -> bool +static Ashlar.Abstractions.Paths.PathContainment.PlatformComparison.get -> System.StringComparison diff --git a/src/Ashlar.BackgroundAgents/Configuration/BackgroundAgentConfigLoader.cs b/src/Ashlar.BackgroundAgents/Configuration/BackgroundAgentConfigLoader.cs index 33fc9085f..fd5af4fb6 100644 --- a/src/Ashlar.BackgroundAgents/Configuration/BackgroundAgentConfigLoader.cs +++ b/src/Ashlar.BackgroundAgents/Configuration/BackgroundAgentConfigLoader.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using NCrontab; @@ -46,18 +47,80 @@ public Task> LoadAsync(CancellationToken cancellatio var section = _configuration.GetSection("BackgroundAgents:Agents"); var configs = new List(); - section.Bind(configs); - - // Validate and process configurations - foreach (var config in configs) + // Bound one agent at a time, not with section.Bind(list), because whether an agent's + // ExfiltrationPolicy SECTION exists is only visible on its own configuration section: once + // bound, an absent section and a written one are the same object, since + // BackgroundAgentConfig.ExfiltrationPolicy is initialised to a default instance. + foreach (var agentSection in section.GetChildren()) { + if (!TryBindAgent(agentSection, out var config)) + continue; + ValidateConfig(config); - ProcessConfig(config); + ProcessConfig(config, agentSection.GetSection(nameof(BackgroundAgentConfig.ExfiltrationPolicy)).Exists()); + configs.Add(config); } return Task.FromResult(configs); } + /// + /// Binds one element of BackgroundAgents:Agents with the outcome + /// section.Bind(list) gave that element, so binding per agent changes nothing but what + /// can be seen about the ExfiltrationPolicy section. + /// + /// + /// Measured against the list bind on the same configurations, with the configuration binder + /// this repository pins (10.0.x), and pinned in BackgroundAgentConfigLoaderGapCoverageTests: + /// + /// A value the binder cannot convert -- "Enabled": "notabool", an unknown + /// Schedule:Type, "BlockExternalLLMs": "yes": the list bind swallowed the + /// binder's exception and LEFT THAT AGENT OUT; the others loaded. Get<T> throws the + /// exception instead, so it is caught here. Leaving the agent out is also the closed outcome -- + /// an agent whose configuration cannot be read does not run -- and it is now logged with the + /// element's path, where it used to be silent. + /// A scalar where an agent object belongs ("Agents": ["x", ...]): left out, as + /// before, and now logged. + /// An EMPTY element ({} or null in JSON, an empty value elsewhere): the list + /// bind bound it to a default instance, which refuses with "Agent ID + /// is required", failing the whole load. Get<T> returns null for it; skipping it + /// would have quietly turned that refusal into a pass, so the default instance is handed back + /// and refused as before. + /// + /// + private bool TryBindAgent(IConfigurationSection agentSection, [NotNullWhen(true)] out BackgroundAgentConfig? config) + { + try + { + config = agentSection.Get(); + } + catch (InvalidOperationException ex) + { + LogAgentLeftOut(agentSection, ex.Message, ex); + config = null; + return false; + } + + if (config != null) + return true; + + if (string.IsNullOrEmpty(agentSection.Value)) + { + config = new BackgroundAgentConfig(); + return true; + } + + LogAgentLeftOut(agentSection, $"'{agentSection.Value}' is a value where an agent object belongs", null); + return false; + } + + private void LogAgentLeftOut(IConfigurationSection agentSection, string reason, Exception? exception) => + _logger?.LogWarning( + exception, + "Background agent at {ConfigPath} was left out: its configuration could not be bound ({Reason}). The other agents are loaded; this one will not run until it is corrected.", + agentSection.Path, + reason); + private void ValidateConfig(BackgroundAgentConfig config) { if (string.IsNullOrWhiteSpace(config.Id)) @@ -125,7 +188,25 @@ private void ValidateSchedule(BackgroundAgentSchedule schedule) } } - private void ProcessConfig(BackgroundAgentConfig config) + /// + /// Applies defaults that depend on the agent's sensitivity level. + /// + /// The bound agent configuration. + /// Whether the agent's configuration contains an + /// ExfiltrationPolicy section at all. + /// + /// An agent with no ExfiltrationPolicy section gets the restrictions its + /// MaxDataSensitivity implies. The derivation below existed before, but it could not fire: + /// it waited for a policy whose MaxAllowedLevel was blank, and an absent section binds to + /// 's defaults, whose MaxAllowedLevel is "Public" -- never + /// blank. So an agent declaring MaxDataSensitivity TopSecret and saying nothing about + /// exfiltration ran with every Block flag false. The only test of the derivation passed because + /// it WROTE a section with a whitespace MaxAllowedLevel. + /// A section that is present is the operator's explicit choice and is kept as written. The + /// old trigger -- a written section whose four flags are all false and whose MaxAllowedLevel is + /// blank -- still derives, so no configuration that used to be restricted loses it. + /// + private void ProcessConfig(BackgroundAgentConfig config, bool exfiltrationPolicySectionPresent) { // Get sensitivity level to determine defaults var sensitivityLevel = _sensitivityRegistry.GetByName(config.MaxDataSensitivity); @@ -135,8 +216,9 @@ private void ProcessConfig(BackgroundAgentConfig config) } // Set default exfiltration policy if not provided - if (config.ExfiltrationPolicy == null || - (config.ExfiltrationPolicy.BlockExternalLLMs == false && + if (!exfiltrationPolicySectionPresent || + config.ExfiltrationPolicy == null || + (config.ExfiltrationPolicy.BlockExternalLLMs == false && config.ExfiltrationPolicy.BlockWebSearch == false && config.ExfiltrationPolicy.BlockNetworkExports == false && config.ExfiltrationPolicy.RequireLocalOnly == false && diff --git a/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityFallbacks.cs b/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityFallbacks.cs new file mode 100644 index 000000000..25bfb3c5c --- /dev/null +++ b/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityFallbacks.cs @@ -0,0 +1,66 @@ +namespace Ashlar.BackgroundAgents.DataSensitivity; + +/// +/// What a missing or unrecognised sensitivity name means, stated once so every consumer fails +/// closed the same way. +/// +/// +/// A clearance and a label fail closed in OPPOSITE directions. A name the registry +/// cannot resolve tells us nothing, and nothing must never widen access: +/// +/// a missing or unknown CLEARANCE (what a caller or agent may read) resolves to the +/// -- the lowest level the registry knows, which is Public unless a custom +/// level was registered below it; +/// a missing or unknown LABEL (what a record or datum is) resolves to +/// -- the highest level the registry knows, never lower than +/// TopSecret. +/// +/// Before this existed each consumer chose for itself, and the choices were open: the +/// legacy vector stores applied no filter at all to a null or unknown clearance and always +/// returned unmarked documents, and reported unmarked data as +/// Public. +/// +public static class DataSensitivityFallbacks +{ + /// The lowest level knows (Public for the primitives). + public static IDataSensitivityLevel Floor(this IDataSensitivityRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + var lowest = DataSensitivityLevels.Public; + foreach (var level in registry.GetAll() ?? Array.Empty()) + { + if (level is not null && level.SensitivityValue < lowest.SensitivityValue) + lowest = level; + } + + return lowest; + } + + /// The highest level knows, and never below TopSecret. + public static IDataSensitivityLevel MostRestrictive(this IDataSensitivityRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + var highest = DataSensitivityLevels.TopSecret; + foreach (var level in registry.GetAll() ?? Array.Empty()) + { + if (level is not null && level.SensitivityValue > highest.SensitivityValue) + highest = level; + } + + return highest; + } + + /// A CLEARANCE by name: missing or unknown resolves to . + public static IDataSensitivityLevel ResolveClearance(this IDataSensitivityRegistry registry, string? levelName) + { + ArgumentNullException.ThrowIfNull(registry); + return registry.GetByName(levelName) ?? registry.Floor(); + } + + /// A LABEL by name: missing or unknown resolves to . + public static IDataSensitivityLevel ResolveLabel(this IDataSensitivityRegistry registry, string? levelName) + { + ArgumentNullException.ThrowIfNull(registry); + return registry.GetByName(levelName) ?? registry.MostRestrictive(); + } +} diff --git a/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityMarker.cs b/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityMarker.cs index 618dae497..eb60b9141 100644 --- a/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityMarker.cs +++ b/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityMarker.cs @@ -20,16 +20,19 @@ public DataSensitivityMarker(IDataSensitivityRegistry registry) } /// - /// Get the sensitivity level for a data object. + /// Get the sensitivity level for a data object. Unmarked (or null) data is the MOST + /// RESTRICTIVE level the registry knows, never Public: nobody classified it, so nothing about + /// it may be assumed safe. It used to report Public, which made every unmarked object readable + /// by every agent through . /// public IDataSensitivityLevel GetSensitivityLevel(object data) { if (data == null) - return DataSensitivityLevels.Public; + return _registry.MostRestrictive(); return _markings.TryGetValue(data, out var level) ? level - : DataSensitivityLevels.Public; + : _registry.MostRestrictive(); } /// diff --git a/src/Ashlar.BackgroundAgents/DataSensitivity/IDataSensitivityMarker.cs b/src/Ashlar.BackgroundAgents/DataSensitivity/IDataSensitivityMarker.cs index 97d42b629..4be6ad787 100644 --- a/src/Ashlar.BackgroundAgents/DataSensitivity/IDataSensitivityMarker.cs +++ b/src/Ashlar.BackgroundAgents/DataSensitivity/IDataSensitivityMarker.cs @@ -18,7 +18,8 @@ public interface IDataSensitivityMarker /// Get the sensitivity level for a data object. /// /// The data object. - /// The sensitivity level, or Public if not marked. + /// The sensitivity level, or the most restrictive level the registry knows if not marked + /// (see ). IDataSensitivityLevel GetSensitivityLevel(object data); /// diff --git a/src/Ashlar.BackgroundAgents/RAG/IKnowledgeBaseIndexer.cs b/src/Ashlar.BackgroundAgents/RAG/IKnowledgeBaseIndexer.cs index 5e6a4aae6..403ebd21c 100644 --- a/src/Ashlar.BackgroundAgents/RAG/IKnowledgeBaseIndexer.cs +++ b/src/Ashlar.BackgroundAgents/RAG/IKnowledgeBaseIndexer.cs @@ -12,7 +12,9 @@ public interface IKnowledgeBaseIndexer /// Index documents from the given paths (files and/or directories). /// /// File or directory paths to index. - /// Sensitivity level to assign to indexed documents (optional). + /// Sensitivity level to assign to indexed documents. Omitted + /// means UNMARKED, which search treats as the most restrictive level, never Public. The CLI's + /// rag index refuses to run without one so the choice is always a deliberate one. /// Cancellation token. /// Number of documents indexed. Task IndexDocumentsAsync( diff --git a/src/Ashlar.BackgroundAgents/RAG/IRAGService.cs b/src/Ashlar.BackgroundAgents/RAG/IRAGService.cs index 35cb7c354..b8792ae9e 100644 --- a/src/Ashlar.BackgroundAgents/RAG/IRAGService.cs +++ b/src/Ashlar.BackgroundAgents/RAG/IRAGService.cs @@ -11,7 +11,9 @@ public interface IRAGService /// Query text. /// Maximum number of results. /// Minimum similarity score (0.0-1.0). - /// If set, only return documents at or below this sensitivity level. + /// The caller's clearance: only documents at or below it are + /// returned. Omitted or unrecognised means the FLOOR (Public), never everything; an unmarked + /// document is treated as the most restrictive level. /// Cancellation token. /// Search results ordered by score descending. Task> SearchAsync( @@ -24,9 +26,12 @@ Task> SearchAsync( /// /// Index a document (or chunk) with optional sensitivity level. /// + /// Re-indexing an existing at a LOWER level than it is stored at + /// is refused with ; remove it first. /// Document id. /// Document text. - /// Optional sensitivity level name. + /// Sensitivity level name. Omitted means UNMARKED, which every + /// search treats as the most restrictive level -- never Public. /// Cancellation token. Task IndexAsync( string id, diff --git a/src/Ashlar.BackgroundAgents/RAG/IVectorStore.cs b/src/Ashlar.BackgroundAgents/RAG/IVectorStore.cs index 14caed182..3abf56d15 100644 --- a/src/Ashlar.BackgroundAgents/RAG/IVectorStore.cs +++ b/src/Ashlar.BackgroundAgents/RAG/IVectorStore.cs @@ -12,7 +12,9 @@ public interface IVectorStore /// Unique document/chunk id. /// Raw text content. /// Embedding vector (same dimension as store). - /// Optional sensitivity level name. + /// Sensitivity level name. Omitted means UNMARKED, which search + /// treats as the most restrictive level. Replacing an existing id with a LOWER level throws + /// . /// Cancellation token. Task IndexAsync( string id, @@ -27,7 +29,8 @@ Task IndexAsync( /// Query embedding. /// Maximum number of results. /// Minimum similarity score (0.0-1.0). - /// If set, only return documents at or below this sensitivity level (requires registry for ordering). + /// The caller's clearance: only documents at or below it are + /// returned. Omitted or unrecognised means the FLOOR (Public), never everything. /// Cancellation token. /// Results ordered by score descending. Task> SearchAsync( diff --git a/src/Ashlar.BackgroundAgents/RAG/InMemoryVectorStore.cs b/src/Ashlar.BackgroundAgents/RAG/InMemoryVectorStore.cs index 6c8754baf..f86858c24 100644 --- a/src/Ashlar.BackgroundAgents/RAG/InMemoryVectorStore.cs +++ b/src/Ashlar.BackgroundAgents/RAG/InMemoryVectorStore.cs @@ -6,18 +6,29 @@ namespace Ashlar.BackgroundAgents.RAG; /// /// In-memory vector store. Stores embeddings and text; supports sensitivity-filtered search. /// +/// +/// Search always filters, and fails closed. A null, blank or unknown clearance is the +/// registry's FLOOR (Public for the primitives), and an unmarked or unknown document label is its +/// MOST RESTRICTIVE level -- see . This store used to apply +/// no filter at all unless BOTH a registry was supplied AND the clearance resolved, and it returned +/// every unmarked document to every caller. +/// Re-indexing never lowers a label. refuses to replace an +/// existing id with a lower-labelled document; remove it first if the lower label is right. +/// public sealed class InMemoryVectorStore : IVectorStore { private readonly ConcurrentDictionary _documents = new(); - private readonly IDataSensitivityRegistry? _sensitivityRegistry; + private readonly IDataSensitivityRegistry _sensitivityRegistry; /// /// Initializes a new instance of the class. /// - /// Optional registry for sensitivity-level filtering in search. + /// Registry that orders sensitivity levels. When null, a + /// registry of the five primitive levels is used, so filtering is never switched off by + /// omission. public InMemoryVectorStore(IDataSensitivityRegistry? sensitivityRegistry = null) { - _sensitivityRegistry = sensitivityRegistry; + _sensitivityRegistry = sensitivityRegistry ?? new DataSensitivityRegistry(); } /// @@ -30,7 +41,17 @@ public Task IndexAsync( { if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); - _documents[id] = (text ?? string.Empty, (float[])embedding.Clone(), sensitivityLevelName); + var incoming = (text ?? string.Empty, (float[])embedding.Clone(), sensitivityLevelName); + // AddOrUpdate, not an indexer write, so the downgrade check and the replacement see the same + // existing document even under a concurrent writer. + _documents.AddOrUpdate( + id, + incoming, + (key, existing) => + { + RagSensitivity.ThrowIfDowngrade(_sensitivityRegistry, key, existing.SensitivityLevelName, sensitivityLevelName); + return incoming; + }); return Task.CompletedTask; } @@ -51,21 +72,15 @@ public Task> SearchAsync( if (!VectorMath.IsRankable(embedding)) throw VectorMath.UnrankableQuery(nameof(embedding)); - IDataSensitivityLevel? maxLevel = null; - if (!string.IsNullOrWhiteSpace(maxSensitivityLevelName) && _sensitivityRegistry != null) - maxLevel = _sensitivityRegistry.GetByName(maxSensitivityLevelName); + var clearance = _sensitivityRegistry.ResolveClearance(maxSensitivityLevelName); var querySpan = embedding.AsSpan(); var results = new List(); foreach (var (docId, doc) in _documents) { - if (maxLevel != null && !string.IsNullOrEmpty(doc.SensitivityLevelName)) - { - var docLevel = _sensitivityRegistry!.GetByName(doc.SensitivityLevelName); - if (docLevel == null || !_sensitivityRegistry.CanAccess(maxLevel, docLevel)) - continue; - } + if (!RagSensitivity.CanRead(_sensitivityRegistry, clearance, doc.SensitivityLevelName)) + continue; // Skip a document that cannot be ranked against this query rather than scoring it -- // a different dimension, or a zero-magnitude embedding. Both used to arrive as the diff --git a/src/Ashlar.BackgroundAgents/RAG/RAGTool.cs b/src/Ashlar.BackgroundAgents/RAG/RAGTool.cs index d528aac00..e09465d7b 100644 --- a/src/Ashlar.BackgroundAgents/RAG/RAGTool.cs +++ b/src/Ashlar.BackgroundAgents/RAG/RAGTool.cs @@ -1,11 +1,20 @@ using System.Text.Json; using Ashlar.Abstractions; +using Ashlar.BackgroundAgents.DataSensitivity; namespace Ashlar.BackgroundAgents.RAG; /// /// ITool that exposes RAG search to agents. Id: "rag_search". /// +/// +/// The model cannot raise its own clearance. The clearance searched at is the AGENT's +/// (maxDataSensitivity in the snapshot). The model's maxSensitivityLevelName argument +/// may only NARROW it; a request above the agent's clearance is ignored, and with no agent +/// clearance in the snapshot the search runs at the floor whatever the model asks for. It used to +/// be the other way round -- the model's value won and the agent's was only a fallback -- so a +/// prompt injection that talked the model into asking for TopSecret got TopSecret. +/// public sealed class RAGTool : ITool { /// @@ -16,11 +25,12 @@ public sealed class RAGTool : ITool private static readonly ToolSchema SchemaInstance = new( DefaultId, "Search the RAG knowledge base for documents similar to the query. Returns matching text chunks with scores.", - """{"type":"object","properties":{"query":{"type":"string","description":"Search query text"},"maxResults":{"type":"integer","description":"Max results (default 5)"},"minScore":{"type":"number","description":"Min similarity 0-1 (default 0.7)"},"maxSensitivityLevelName":{"type":"string","description":"Only return docs at or below this sensitivity level"}},"required":["query"]}"""); + """{"type":"object","properties":{"query":{"type":"string","description":"Search query text"},"maxResults":{"type":"integer","description":"Max results (default 5)"},"minScore":{"type":"number","description":"Min similarity 0-1 (default 0.7)"},"maxSensitivityLevelName":{"type":"string","description":"Optionally narrow the search below your clearance; it cannot raise it"}},"required":["query"]}"""); private readonly IRAGService _ragService; private readonly int _defaultMaxResults; private readonly double _defaultMinScore; + private readonly IDataSensitivityRegistry _sensitivityRegistry; /// public string Id => DefaultId; @@ -34,11 +44,18 @@ public sealed class RAGTool : ITool /// RAG service. /// Default max results when not specified (default 5). /// Default min score when not specified (default 0.7). - public RAGTool(IRAGService ragService, int defaultMaxResults = 5, double defaultMinScore = 0.7) + /// Registry used to compare the agent's clearance with the one + /// the model asks for; the five primitive levels when null. + public RAGTool( + IRAGService ragService, + int defaultMaxResults = 5, + double defaultMinScore = 0.7, + IDataSensitivityRegistry? sensitivityRegistry = null) { _ragService = ragService ?? throw new ArgumentNullException(nameof(ragService)); _defaultMaxResults = defaultMaxResults; _defaultMinScore = defaultMinScore; + _sensitivityRegistry = sensitivityRegistry ?? new DataSensitivityRegistry(); } /// @@ -48,9 +65,7 @@ public async Task InvokeAsync(ToolCall toolCall, WorldSnapshot s, Ca var query = args.Query ?? string.Empty; var maxResults = args.MaxResults ?? _defaultMaxResults; var minScore = args.MinScore ?? _defaultMinScore; - var maxSensitivity = args.MaxSensitivityLevelName; - if (string.IsNullOrEmpty(maxSensitivity) && s.Data.TryGetValue("maxDataSensitivity", out var levelObj) && levelObj is string levelName) - maxSensitivity = levelName; + var maxSensitivity = EffectiveClearance(s, args.MaxSensitivityLevelName); var tick = s.Tick; @@ -90,6 +105,27 @@ public async Task InvokeAsync(ToolCall toolCall, WorldSnapshot s, Ca return new ToolResult(delta, payload); } + /// + /// The agent's clearance, narrowed (never widened) by what the model asked for. Null -- the + /// floor, downstream -- when the snapshot carries no agent clearance. + /// + private string? EffectiveClearance(WorldSnapshot s, string? requested) + { + if (!s.Data.TryGetValue("maxDataSensitivity", out var levelObj) + || levelObj is not string agentClearance + || string.IsNullOrWhiteSpace(agentClearance)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(requested) || _sensitivityRegistry.GetByName(requested) is not { } requestedLevel) + return agentClearance; + + return requestedLevel.SensitivityValue < _sensitivityRegistry.ResolveClearance(agentClearance).SensitivityValue + ? requestedLevel.Value + : agentClearance; + } + private static RAGSearchArgs ParseArgs(ToolCall call) { try diff --git a/src/Ashlar.BackgroundAgents/RAG/RagSensitivity.cs b/src/Ashlar.BackgroundAgents/RAG/RagSensitivity.cs new file mode 100644 index 000000000..c61facc04 --- /dev/null +++ b/src/Ashlar.BackgroundAgents/RAG/RagSensitivity.cs @@ -0,0 +1,40 @@ +using Ashlar.BackgroundAgents.DataSensitivity; + +namespace Ashlar.BackgroundAgents.RAG; + +/// +/// The two sensitivity decisions both legacy vector stores make, in one place so they cannot +/// drift apart: may this clearance read this label, and may this label replace that one. +/// +internal static class RagSensitivity +{ + /// + /// Whether a caller at may read a document labelled + /// . An unmarked or unknown label is the most restrictive + /// level the registry knows (). + /// + public static bool CanRead(IDataSensitivityRegistry registry, IDataSensitivityLevel clearance, string? documentLabel) => + registry.CanAccess(clearance, registry.ResolveLabel(documentLabel)); + + /// + /// Refuses to replace a stored document with one labelled LOWER. Both labels resolve through + /// , so an unmarked stored document counts as + /// the most restrictive and cannot be relabelled downward by re-indexing either. + /// + /// The incoming label is lower than the stored one. + public static void ThrowIfDowngrade(IDataSensitivityRegistry registry, string id, string? storedLabel, string? incomingLabel) + { + var stored = registry.ResolveLabel(storedLabel); + var incoming = registry.ResolveLabel(incomingLabel); + if (incoming.SensitivityValue < stored.SensitivityValue) + { + throw new InvalidOperationException( + $"Refusing to re-index '{id}' at '{Describe(incomingLabel)}': it is already stored at " + + $"'{Describe(storedLabel)}' (treated as {stored.Value}), and re-indexing must never lower a " + + "document's sensitivity. If the lower label is correct, remove the document first and index " + + "it again -- lowering a label has to be a separate, deliberate act."); + } + } + + private static string Describe(string? label) => string.IsNullOrWhiteSpace(label) ? "(unmarked)" : label; +} diff --git a/src/Ashlar.BackgroundAgents/RAG/SqliteVectorStore.cs b/src/Ashlar.BackgroundAgents/RAG/SqliteVectorStore.cs index 228ede2bb..10e191790 100644 --- a/src/Ashlar.BackgroundAgents/RAG/SqliteVectorStore.cs +++ b/src/Ashlar.BackgroundAgents/RAG/SqliteVectorStore.cs @@ -7,10 +7,19 @@ namespace Ashlar.BackgroundAgents.RAG; /// /// SQLite-backed vector store. Persists embeddings to a SQLite database; search loads and scores in memory. /// +/// +/// Search always filters, and fails closed, exactly as +/// does: a null, blank or unknown clearance is the registry's FLOOR, and a row with no label (or a +/// label the registry does not know) is its MOST RESTRICTIVE level. Rows written before this change +/// with a NULL sensitivity_level_name are therefore visible only to the top clearance until +/// they are relabelled (remove, then index again). +/// Re-indexing never lowers a label: replacing an id with a lower-labelled row is +/// refused inside the same transaction that would have written it. +/// public sealed class SqliteVectorStore : IVectorStore, IAsyncDisposable { private readonly string _connectionString; - private readonly IDataSensitivityRegistry? _sensitivityRegistry; + private readonly IDataSensitivityRegistry _sensitivityRegistry; private readonly SemaphoreSlim _initLock = new(1, 1); private bool _initialized; @@ -25,7 +34,8 @@ public sealed class SqliteVectorStore : IVectorStore, IAsyncDisposable /// Initializes a new instance of the class. /// /// SQLite connection string or file path (e.g. "Data Source=rag.db"). - /// Optional registry for sensitivity-level filtering in search. + /// Registry that orders sensitivity levels. When null, a registry + /// of the five primitive levels is used, so filtering is never switched off by omission. public SqliteVectorStore(string connectionStringOrPath, IDataSensitivityRegistry? sensitivityRegistry = null) { if (string.IsNullOrWhiteSpace(connectionStringOrPath)) @@ -33,7 +43,7 @@ public SqliteVectorStore(string connectionStringOrPath, IDataSensitivityRegistry _connectionString = connectionStringOrPath.Trim().StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase) ? connectionStringOrPath : $"Data Source={connectionStringOrPath}"; - _sensitivityRegistry = sensitivityRegistry; + _sensitivityRegistry = sensitivityRegistry ?? new DataSensitivityRegistry(); } /// @@ -51,7 +61,23 @@ public async Task IndexAsync( var blob = FloatArrayToBlob(embedding); await using var conn = new SqliteConnection(_connectionString); await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var tx = conn.BeginTransaction(); + + await using (var existing = conn.CreateCommand()) + { + existing.Transaction = tx; + existing.CommandText = "SELECT sensitivity_level_name FROM rag_vectors WHERE id = $id"; + existing.Parameters.AddWithValue("$id", id); + await using var reader = await existing.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var storedLabel = reader.IsDBNull(0) ? null : reader.GetString(0); + RagSensitivity.ThrowIfDowngrade(_sensitivityRegistry, id, storedLabel, sensitivityLevelName); + } + } + await using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; cmd.CommandText = """ INSERT OR REPLACE INTO rag_vectors (id, text, embedding, sensitivity_level_name) VALUES ($id, $text, $embedding, $sensitivity_level_name) @@ -61,6 +87,7 @@ INSERT OR REPLACE INTO rag_vectors (id, text, embedding, sensitivity_level_name) cmd.Parameters.AddWithValue("$embedding", blob); cmd.Parameters.AddWithValue("$sensitivity_level_name", (object?)sensitivityLevelName ?? DBNull.Value); await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); } /// @@ -84,9 +111,7 @@ public async Task> SearchAsync( await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false); - IDataSensitivityLevel? maxLevel = null; - if (!string.IsNullOrWhiteSpace(maxSensitivityLevelName) && _sensitivityRegistry != null) - maxLevel = _sensitivityRegistry.GetByName(maxSensitivityLevelName); + var clearance = _sensitivityRegistry.ResolveClearance(maxSensitivityLevelName); var results = new List(); @@ -103,12 +128,8 @@ public async Task> SearchAsync( var blob = (byte[]?)reader.GetValue(2); var sensitivityLevelName = reader.IsDBNull(3) ? null : reader.GetString(3); - if (maxLevel != null && !string.IsNullOrEmpty(sensitivityLevelName)) - { - var docLevel = _sensitivityRegistry!.GetByName(sensitivityLevelName); - if (docLevel == null || !_sensitivityRegistry.CanAccess(maxLevel, docLevel)) - continue; - } + if (!RagSensitivity.CanRead(_sensitivityRegistry, clearance, sensitivityLevelName)) + continue; if (blob == null || blob.Length == 0) continue; diff --git a/src/Ashlar.BackgroundAgents/Security/DataExfiltrationPolicy.cs b/src/Ashlar.BackgroundAgents/Security/DataExfiltrationPolicy.cs index d978bc2ca..8aaaa6d8c 100644 --- a/src/Ashlar.BackgroundAgents/Security/DataExfiltrationPolicy.cs +++ b/src/Ashlar.BackgroundAgents/Security/DataExfiltrationPolicy.cs @@ -13,6 +13,16 @@ namespace Ashlar.BackgroundAgents.Security; /// then applies ExfiltrationPolicy to block tool calls that would send data to /// external LLMs, web search, or network exports when disallowed. /// Implements IPolicy for use with PolicyEngine. +/// +/// An agent it cannot identify gets the MOST restrictive policy, not none. When the +/// snapshot carries no usable agentId, or names an agent the registry does not hold, every +/// block this policy knows applies (external LLM, web search, network export, local-only). It used +/// to APPROVE such a call with reason "OK", so a caller could escape every exfiltration rule by +/// omitting or misspelling its own id. The scope is unchanged: this policy only ever refuses the +/// exfiltration-class tool ids below, so a local tool (a file read, a build) is still approved +/// for an unidentified agent exactly as for the most restricted registered one. That the tool +/// lists are deny-lists -- an exfiltrating tool with an id not on them passes -- is a separate, +/// known gap. /// public sealed class DataExfiltrationPolicy : IPolicy { @@ -31,6 +41,16 @@ public sealed class DataExfiltrationPolicy : IPolicy "export", "Export", "upload", "Upload", "send_to_network", "SendToNetwork", "http_post", "HttpPost" }; + /// What an agent that cannot be identified is held to: every block at once. + private static readonly ExfiltrationPolicy MostRestrictivePolicy = new() + { + BlockExternalLLMs = true, + BlockWebSearch = true, + BlockNetworkExports = true, + RequireLocalOnly = true, + MaxAllowedLevel = DataSensitivityLevels.Public.Value, + }; + private readonly IBackgroundAgentRegistry _registry; private readonly IDataSensitivityRegistry _sensitivityRegistry; private readonly IReadOnlySet _externalLlmToolIds; @@ -70,37 +90,55 @@ public bool Approve(ToolCall call, WorldSnapshot s, out string reason) { reason = string.Empty; - if (!s.Data.TryGetValue("agentId", out var agentIdObj) || agentIdObj is not string agentId) + // Resolve whose policy applies. Every branch that cannot name a registered agent falls to + // the most restrictive policy, with the reason saying why, so a refusal of an unidentified + // caller reads differently from a refusal of a known one. + ExfiltrationPolicy policy; + var basis = "Exfiltration policy"; + if (!s.Data.TryGetValue("agentId", out var agentIdObj) + || agentIdObj is not string agentId + || string.IsNullOrWhiteSpace(agentId)) { - reason = "OK"; - return true; + policy = MostRestrictivePolicy; + basis = "Exfiltration policy (no agent id in the snapshot; most restrictive policy applied)"; } - - var instance = _registry.GetAgent(agentId); - if (instance == null) + else if (_registry.GetAgent(agentId) is not { } instance) + { + policy = MostRestrictivePolicy; + basis = $"Exfiltration policy (agent '{agentId}' is not registered; most restrictive policy applied)"; + } + else if (instance.Config?.ExfiltrationPolicy is not { } configured) + { + // A registered agent with no config, or a config whose policy was nulled after loading + // (BackgroundAgentConfig.ExfiltrationPolicy is non-nullable but settable, and not every + // registration goes through the loader). Before this class failed closed, this line + // threw a NullReferenceException; the permissive `new ExfiltrationPolicy()` is the + // tempting fix and the wrong one. + policy = MostRestrictivePolicy; + basis = $"Exfiltration policy (agent '{agentId}' has no exfiltration policy; most restrictive policy applied)"; + } + else { - reason = "OK"; - return true; + policy = configured; } - var policy = instance.Config.ExfiltrationPolicy; var toolId = call.Id ?? string.Empty; if (policy.BlockExternalLLMs && _externalLlmToolIds.Contains(toolId)) { - reason = $"Exfiltration policy blocks external LLM tool: {toolId}"; + reason = $"{basis} blocks external LLM tool: {toolId}"; return false; } if (policy.BlockWebSearch && _webSearchToolIds.Contains(toolId)) { - reason = $"Exfiltration policy blocks web search tool: {toolId}"; + reason = $"{basis} blocks web search tool: {toolId}"; return false; } if (policy.BlockNetworkExports && _networkExportToolIds.Contains(toolId)) { - reason = $"Exfiltration policy blocks network export tool: {toolId}"; + reason = $"{basis} blocks network export tool: {toolId}"; return false; } @@ -108,7 +146,7 @@ public bool Approve(ToolCall call, WorldSnapshot s, out string reason) { if (_externalLlmToolIds.Contains(toolId) || _webSearchToolIds.Contains(toolId) || _networkExportToolIds.Contains(toolId)) { - reason = $"Exfiltration policy requires local-only; tool {toolId} is not local"; + reason = $"{basis} requires local-only; tool {toolId} is not local"; return false; } } diff --git a/src/Ashlar.BackgroundAgents/Trust/CloudSanitizationProxy.cs b/src/Ashlar.BackgroundAgents/Trust/CloudSanitizationProxy.cs index f29b3229a..2175992d5 100644 --- a/src/Ashlar.BackgroundAgents/Trust/CloudSanitizationProxy.cs +++ b/src/Ashlar.BackgroundAgents/Trust/CloudSanitizationProxy.cs @@ -7,6 +7,17 @@ namespace Ashlar.BackgroundAgents.Trust; /// Default implementation of ICloudSanitizationProxy. /// Uses ISensitiveContentFilter for PII and IDataTaxonomy for classification. /// +/// +/// No filter means no cloud. Without an this proxy +/// cannot tell a clean prompt from one carrying PII, so every prompt bound for a cloud provider is +/// BLOCKED (and the block is audited) rather than passed through. It used to pass everything +/// through unaudited, and that was the state ashlar improve ran in with trust "enabled": +/// its DI registration built this class with every optional dependency null, so the trust switch +/// wrapped the provider in a sanitizer that sanitized nothing. Air-gapped contexts are still +/// allowed unfiltered, because they leave the machine for no cloud at all. +/// The taxonomy is accepted but not yet consulted; classification-driven blocking is a later +/// step. It is the content filter, not the taxonomy, whose absence blocks. +/// public sealed class CloudSanitizationProxy : ICloudSanitizationProxy { private const string RuleVersion = "trust-v1"; @@ -17,9 +28,10 @@ public sealed class CloudSanitizationProxy : ICloudSanitizationProxy /// /// Creates a new cloud sanitization proxy. /// - /// Optional PII filter. If null, PII checks are skipped. + /// PII filter. If null, every non-air-gapped prompt is BLOCKED: a + /// sanitizer that cannot inspect content must not wave it through. /// Optional taxonomy. If null, no data-type classification. - /// Optional audit log. If null, redactions are not persisted. + /// Optional audit log. If null, redactions and blocks are not persisted. public CloudSanitizationProxy( ISensitiveContentFilter? contentFilter = null, IDataTaxonomy? taxonomy = null, @@ -38,46 +50,36 @@ public SanitizationResult SanitizeForCloud(OutgoingContext context, Cancellation if (context.IsAirGapped) return SanitizationResult.AllowedWith(context); + if (_contentFilter == null) + { + Audit("blocked", "No sensitive-content filter configured"); + return SanitizationResult.Blocked( + "No sensitive-content filter is configured, so the prompt cannot be checked for PII; " + + "refusing to send it to a cloud provider unfiltered. Register an ISensitiveContentFilter " + + "(AddTrustServices registers the default one)."); + } + var redactions = new List(); var systemPrompt = context.SystemPrompt ?? string.Empty; var userPrompt = context.UserPrompt ?? string.Empty; // PII check: block if query contains PII - if (_contentFilter != null) + var combined = systemPrompt + "\n" + userPrompt; + if (_contentFilter.ShouldBlockQuery(combined)) { - var combined = systemPrompt + "\n" + userPrompt; - if (_contentFilter.ShouldBlockQuery(combined)) - { - var entry = new SanitizationAuditEntry - { - Timestamp = DateTimeOffset.UtcNow, - RuleVersion = RuleVersion, - FieldOrType = "prompt", - Disposition = "blocked", - Reason = "PII detected in prompt", - }; - redactions.Add(entry); - _auditLog?.LogRedaction(entry.Timestamp, entry.RuleVersion, entry.FieldOrType, entry.Disposition, entry.Reason); - return SanitizationResult.Blocked("Prompt contains PII; blocked per policy."); - } + Audit("blocked", "PII detected in prompt"); + return SanitizationResult.Blocked("Prompt contains PII; blocked per policy."); + } - var filteredSystem = _contentFilter.FilterQuery(systemPrompt); - var filteredUser = _contentFilter.FilterQuery(userPrompt); - if (filteredSystem != systemPrompt || filteredUser != userPrompt) - { - var entry = new SanitizationAuditEntry - { - Timestamp = DateTimeOffset.UtcNow, - RuleVersion = RuleVersion, - FieldOrType = "prompt", - Disposition = "redacted", - Reason = "PII redacted", - }; - redactions.Add(entry); - _auditLog?.LogRedaction(entry.Timestamp, entry.RuleVersion, entry.FieldOrType, entry.Disposition, entry.Reason); - systemPrompt = filteredSystem; - userPrompt = filteredUser; - } + var filteredSystem = _contentFilter.FilterQuery(systemPrompt); + var filteredUser = _contentFilter.FilterQuery(userPrompt); + if (filteredSystem != systemPrompt || filteredUser != userPrompt) + { + // Logged once, here. A second loop over `redactions` used to log every entry again, so + // the audit trail showed two redactions for every one that happened. + redactions.Add(Audit("redacted", "PII redacted")); + systemPrompt = filteredSystem; + userPrompt = filteredUser; } var sanitized = new OutgoingContext @@ -89,9 +91,20 @@ public SanitizationResult SanitizeForCloud(OutgoingContext context, Cancellation Provider = context.Provider, }; - foreach (var r in redactions) - _auditLog?.LogRedaction(r.Timestamp, r.RuleVersion, r.FieldOrType, r.Disposition, r.Reason); - return SanitizationResult.AllowedWith(sanitized, redactions); } + + private SanitizationAuditEntry Audit(string disposition, string reason) + { + var entry = new SanitizationAuditEntry + { + Timestamp = DateTimeOffset.UtcNow, + RuleVersion = RuleVersion, + FieldOrType = "prompt", + Disposition = disposition, + Reason = reason, + }; + _auditLog?.LogRedaction(entry.Timestamp, entry.RuleVersion, entry.FieldOrType, entry.Disposition, entry.Reason); + return entry; + } } diff --git a/src/Ashlar.BackgroundAgents/Trust/ICloudSanitizationProxy.cs b/src/Ashlar.BackgroundAgents/Trust/ICloudSanitizationProxy.cs index fc400689c..2ce62ba48 100644 --- a/src/Ashlar.BackgroundAgents/Trust/ICloudSanitizationProxy.cs +++ b/src/Ashlar.BackgroundAgents/Trust/ICloudSanitizationProxy.cs @@ -2,7 +2,8 @@ namespace Ashlar.BackgroundAgents.Trust; /// /// Sanitizes outgoing LLM/vision context before delegation to IProviderFactory. -/// Blocks if classification uncertain. Logs all redactions. +/// Blocks if classification uncertain -- and an implementation that cannot inspect content at all +/// (no content filter) must block, never allow. Logs all redactions. /// public interface ICloudSanitizationProxy { diff --git a/src/Ashlar.Brick.Contracts/Authoring/Ports/AgentProfile.cs b/src/Ashlar.Brick.Contracts/Authoring/Ports/AgentProfile.cs index cd2c980ce..b46f21162 100644 --- a/src/Ashlar.Brick.Contracts/Authoring/Ports/AgentProfile.cs +++ b/src/Ashlar.Brick.Contracts/Authoring/Ports/AgentProfile.cs @@ -1,5 +1,3 @@ -#pragma warning disable RS0016 - namespace Ashlar.Core.Domain.Bricks.Ports; /// diff --git a/src/Ashlar.Brick.Contracts/Authoring/Ports/BrickConstraintManifest.cs b/src/Ashlar.Brick.Contracts/Authoring/Ports/BrickConstraintManifest.cs index e99bed1ad..d282974d3 100644 --- a/src/Ashlar.Brick.Contracts/Authoring/Ports/BrickConstraintManifest.cs +++ b/src/Ashlar.Brick.Contracts/Authoring/Ports/BrickConstraintManifest.cs @@ -1,5 +1,3 @@ -#pragma warning disable RS0016 // Profile ports ship via Unshipped incrementally; suppress until batched. - using System.Text; namespace Ashlar.Core.Domain.Bricks.Ports; diff --git a/src/Ashlar.Brick.Contracts/Authoring/Ports/GenerationRequest.cs b/src/Ashlar.Brick.Contracts/Authoring/Ports/GenerationRequest.cs index 29bdcba1c..04016f0f1 100644 --- a/src/Ashlar.Brick.Contracts/Authoring/Ports/GenerationRequest.cs +++ b/src/Ashlar.Brick.Contracts/Authoring/Ports/GenerationRequest.cs @@ -1,5 +1,3 @@ -#pragma warning disable RS0016 // Profile ports ship via Unshipped incrementally; suppress until batched. - namespace Ashlar.Core.Domain.Bricks.Ports; /// Grounding and destination for a generative draft. diff --git a/src/Ashlar.Brick.Contracts/Authoring/Ports/IAcceptanceEvaluator.cs b/src/Ashlar.Brick.Contracts/Authoring/Ports/IAcceptanceEvaluator.cs index f98ec277c..568cc5da6 100644 --- a/src/Ashlar.Brick.Contracts/Authoring/Ports/IAcceptanceEvaluator.cs +++ b/src/Ashlar.Brick.Contracts/Authoring/Ports/IAcceptanceEvaluator.cs @@ -1,5 +1,3 @@ -#pragma warning disable RS0016 - namespace Ashlar.Core.Domain.Bricks.Ports; /// diff --git a/src/Ashlar.Brick.Contracts/Authoring/Ports/IArtifactDrafter.cs b/src/Ashlar.Brick.Contracts/Authoring/Ports/IArtifactDrafter.cs index ff5719707..d4d9dd020 100644 --- a/src/Ashlar.Brick.Contracts/Authoring/Ports/IArtifactDrafter.cs +++ b/src/Ashlar.Brick.Contracts/Authoring/Ports/IArtifactDrafter.cs @@ -1,5 +1,3 @@ -#pragma warning disable RS0016 - namespace Ashlar.Core.Domain.Bricks.Ports; /// diff --git a/src/Ashlar.Brick.Contracts/PublicAPI.Shipped.txt b/src/Ashlar.Brick.Contracts/PublicAPI.Shipped.txt index 261e1e0fa..f90edd820 100644 --- a/src/Ashlar.Brick.Contracts/PublicAPI.Shipped.txt +++ b/src/Ashlar.Brick.Contracts/PublicAPI.Shipped.txt @@ -463,3 +463,150 @@ const Ashlar.Core.Domain.Bricks.GenerativeBrick.ProvenanceOutputKey = "generativ static Ashlar.Core.Domain.Bricks.GenerativeBrick.ApplyAuditPolicy(Ashlar.Core.Domain.Bricks.GenerativeProvenance provenance, Ashlar.Core.Domain.Execution.IExecutionContext context) -> Ashlar.Core.Domain.Bricks.GenerativeProvenance static Ashlar.Core.Domain.Bricks.GenerativeBrick.EmitProvenance(Ashlar.Core.Domain.Execution.BrickOutput output, Ashlar.Core.Domain.Bricks.GenerativeProvenance provenance) -> void static Ashlar.Core.Domain.Bricks.GenerativeBrick.RouteHumanReviewIfRequiredAsync(Ashlar.Core.Domain.Bricks.GenerativeProvenance provenance, System.Func> requestApproval, string actionDescription, System.TimeSpan timeout, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task +Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext +Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext.AcceptanceContext(Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact! Artifact, Ashlar.Core.Domain.Bricks.GenerativeProvenance! Provenance, Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult! Smoke) -> void +Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext.Artifact.get -> Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact! +Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext.Artifact.init -> void +Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext.Provenance.get -> Ashlar.Core.Domain.Bricks.GenerativeProvenance! +Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext.Provenance.init -> void +Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext.Smoke.get -> Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult! +Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext.Smoke.init -> void +Ashlar.Core.Domain.Bricks.Ports.AcceptanceDecision +Ashlar.Core.Domain.Bricks.Ports.AcceptanceDecision.Rollback = 1 -> Ashlar.Core.Domain.Bricks.Ports.AcceptanceDecision +Ashlar.Core.Domain.Bricks.Ports.AcceptanceDecision.Ship = 0 -> Ashlar.Core.Domain.Bricks.Ports.AcceptanceDecision +Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult +Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.AcceptanceResult(Ashlar.Core.Domain.Bricks.Ports.AcceptanceDecision Decision, string! Reason, System.Collections.Generic.IReadOnlyList! Signals) -> void +Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.Decision.get -> Ashlar.Core.Domain.Bricks.Ports.AcceptanceDecision +Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.Decision.init -> void +Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.Reason.get -> string! +Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.Reason.init -> void +Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.Signals.get -> System.Collections.Generic.IReadOnlyList! +Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.Signals.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Acceptance.get -> Ashlar.Core.Domain.Bricks.Ports.IAcceptanceEvaluator? +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Acceptance.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.AgentProfile() -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Capabilities.get -> Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities! +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Capabilities.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.ConstraintManifest.get -> Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest? +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.ConstraintManifest.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Deployment.get -> Ashlar.Core.Domain.Bricks.Ports.IDeploymentTarget? +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Deployment.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.DeterministicDrafter.get -> Ashlar.Core.Domain.Bricks.Ports.IDeterministicDrafter? +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.DeterministicDrafter.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Drafter.get -> Ashlar.Core.Domain.Bricks.Ports.IArtifactDrafter! +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Drafter.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Knowledge.get -> Ashlar.Core.Domain.Bricks.DomainKnowledge! +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Knowledge.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Llm.get -> Ashlar.Core.Domain.Bricks.LLMConfig! +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Llm.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Sandbox.get -> Ashlar.Core.Domain.Bricks.Ports.ISandboxProvider? +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Sandbox.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.TargetId.get -> string! +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.TargetId.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Tunables.get -> Ashlar.Core.Domain.Bricks.Ports.GenerationTunables! +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Tunables.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Validators.get -> System.Collections.Generic.IReadOnlyList! +Ashlar.Core.Domain.Bricks.Ports.AgentProfile.Validators.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities +Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities.AgentProfileCapabilities() -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities.SupportsDeployment.get -> bool +Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities.SupportsDeployment.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities.SupportsDeterministic.get -> bool +Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities.SupportsDeterministic.init -> void +Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities.SupportsSandbox.get -> bool +Ashlar.Core.Domain.Bricks.Ports.AgentProfileCapabilities.SupportsSandbox.init -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.AllowedUsings.get -> System.Collections.Generic.IReadOnlyList! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.AllowedUsings.init -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.BaseTypeInstruction() -> string! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.BrickConstraintManifest() -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.ClassNameInstruction() -> string! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.ForbiddenApiInstruction(string! token) -> string! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.ForbiddenApiTokens.get -> System.Collections.Generic.IReadOnlyList! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.ForbiddenApiTokens.init -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.ForbiddenNamespaceInstruction(string! ns) -> string! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.ForbiddenNamespaces.get -> System.Collections.Generic.IReadOnlyList! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.ForbiddenNamespaces.init -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.NamespaceInstruction() -> string! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RenderInstructions() -> string! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredBaseType.get -> string? +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredBaseType.init -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredClassNameSuffix.get -> string? +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredClassNameSuffix.init -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredDeclarationInstruction(string! declaration) -> string! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredDeclarations.get -> System.Collections.Generic.IReadOnlyList! +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredDeclarations.init -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredNamespace.get -> string? +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.RequiredNamespace.init -> void +Ashlar.Core.Domain.Bricks.Ports.BrickConstraintManifest.UsingsInstruction() -> string! +Ashlar.Core.Domain.Bricks.Ports.DefaultAcceptanceEvaluator +Ashlar.Core.Domain.Bricks.Ports.DefaultAcceptanceEvaluator.DefaultAcceptanceEvaluator() -> void +Ashlar.Core.Domain.Bricks.Ports.DefaultAcceptanceEvaluator.Evaluate(Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext! context) -> Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult! +Ashlar.Core.Domain.Bricks.Ports.DeploymentApplyResult +Ashlar.Core.Domain.Bricks.Ports.DeploymentApplyResult.DeploymentApplyResult() -> void +Ashlar.Core.Domain.Bricks.Ports.DeploymentApplyResult.Destination.get -> string? +Ashlar.Core.Domain.Bricks.Ports.DeploymentApplyResult.Destination.init -> void +Ashlar.Core.Domain.Bricks.Ports.DeploymentApplyResult.Message.get -> string? +Ashlar.Core.Domain.Bricks.Ports.DeploymentApplyResult.Message.init -> void +Ashlar.Core.Domain.Bricks.Ports.DeploymentApplyResult.Succeeded.get -> bool +Ashlar.Core.Domain.Bricks.Ports.DeploymentApplyResult.Succeeded.init -> void +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.DeploymentSmokeResult(bool Ran, bool Succeeded, string? Detail, System.Collections.Generic.IReadOnlyDictionary! Outputs) -> void +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Detail.get -> string? +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Detail.init -> void +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Outputs.get -> System.Collections.Generic.IReadOnlyDictionary! +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Outputs.init -> void +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Ran.get -> bool +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Ran.init -> void +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Succeeded.get -> bool +Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Succeeded.init -> void +Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact +Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact.Content.get -> string! +Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact.Content.init -> void +Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact.Files.get -> System.Collections.Generic.IReadOnlyDictionary! +Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact.Files.init -> void +Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact.GeneratedArtifact() -> void +Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact.Provenance.get -> Ashlar.Core.Domain.Bricks.GenerativeProvenance? +Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact.Provenance.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.ConstraintInstructions.get -> string! +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.ConstraintInstructions.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.ContextHash.get -> string? +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.ContextHash.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.GenerationRequest() -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.Grounding.get -> string! +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.Grounding.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.OutputPath.get -> string? +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.OutputPath.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.Overrides.get -> System.Collections.Generic.IReadOnlyDictionary! +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.Overrides.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.TargetId.get -> string! +Ashlar.Core.Domain.Bricks.Ports.GenerationRequest.TargetId.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationTunables +Ashlar.Core.Domain.Bricks.Ports.GenerationTunables.GenerationTunables() -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationTunables.MaxRepairAttempts.get -> int +Ashlar.Core.Domain.Bricks.Ports.GenerationTunables.MaxRepairAttempts.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationTunables.PreferDeterministic.get -> bool +Ashlar.Core.Domain.Bricks.Ports.GenerationTunables.PreferDeterministic.init -> void +Ashlar.Core.Domain.Bricks.Ports.GenerationTunables.RequireHumanReviewForModelDrafts.get -> bool +Ashlar.Core.Domain.Bricks.Ports.GenerationTunables.RequireHumanReviewForModelDrafts.init -> void +Ashlar.Core.Domain.Bricks.Ports.IAcceptanceEvaluator +Ashlar.Core.Domain.Bricks.Ports.IAcceptanceEvaluator.Evaluate(Ashlar.Core.Domain.Bricks.Ports.AcceptanceContext! context) -> Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult! +Ashlar.Core.Domain.Bricks.Ports.IAcceptanceGatedDeploymentTarget +Ashlar.Core.Domain.Bricks.Ports.IAcceptanceGatedDeploymentTarget.ApplyAsync(Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact! artifact, Ashlar.Core.Domain.Bricks.Ports.IAcceptanceEvaluator! acceptance, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Ashlar.Core.Domain.Bricks.Ports.IArtifactDrafter +Ashlar.Core.Domain.Bricks.Ports.IArtifactDrafter.DraftAsync(Ashlar.Core.Domain.Bricks.Ports.GenerationRequest! request, System.Collections.Generic.IReadOnlyList! priorFeedback, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Ashlar.Core.Domain.Bricks.Ports.IDeploymentTarget +Ashlar.Core.Domain.Bricks.Ports.IDeploymentTarget.ApplyAsync(Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact! artifact, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Ashlar.Core.Domain.Bricks.Ports.IDeterministicDrafter +Ashlar.Core.Domain.Bricks.Ports.IDeterministicDrafter.TryDraft(Ashlar.Core.Domain.Bricks.Ports.GenerationRequest! request, out Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact? artifact) -> bool +Ashlar.Core.Domain.Bricks.Ports.ISandboxProvider +Ashlar.Core.Domain.Bricks.Ports.ISandboxProvider.BuildSpec(Ashlar.Core.Domain.Bricks.Ports.GeneratedArtifact! artifact, string! scratchRoot) -> object! +const Ashlar.Core.Domain.Bricks.Ports.DefaultAcceptanceEvaluator.HumanReviewSignal = "requires-human-review" -> string! +static Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.Rollback(string! reason, System.Collections.Generic.IReadOnlyList? signals = null) -> Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult! +static Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult.Ship(string! reason, System.Collections.Generic.IReadOnlyList? signals = null) -> Ashlar.Core.Domain.Bricks.Ports.AcceptanceResult! +static Ashlar.Core.Domain.Bricks.Ports.DefaultAcceptanceEvaluator.Instance.get -> Ashlar.Core.Domain.Bricks.Ports.DefaultAcceptanceEvaluator! +static Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Fail(string? detail, System.Collections.Generic.IReadOnlyDictionary? outputs = null) -> Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult! +static Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.NotRun.get -> Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult! +static Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult.Pass(string? detail, System.Collections.Generic.IReadOnlyDictionary? outputs = null) -> Ashlar.Core.Domain.Bricks.Ports.DeploymentSmokeResult! diff --git a/src/Ashlar.Core.Application/Paths/MediatedWritePath.cs b/src/Ashlar.Core.Application/Paths/MediatedWritePath.cs index 029cbd529..45dd84c3a 100755 --- a/src/Ashlar.Core.Application/Paths/MediatedWritePath.cs +++ b/src/Ashlar.Core.Application/Paths/MediatedWritePath.cs @@ -1,3 +1,5 @@ +using Ashlar.Abstractions.Paths; + namespace Ashlar.Core.Application.Paths; /// @@ -70,7 +72,10 @@ public static class MediatedWritePath : rootFull + Path.DirectorySeparatorChar; var fullPath = Path.GetFullPath(Path.Combine(rootFull, target)); - if (!fullPath.StartsWith(rootWithSep, StringComparison.Ordinal)) + // Strictly inside: the root itself is not a write target. Ordinal, deliberately, on every + // platform: a spelling that differs from the root only by case is refused as an escape + // rather than judged, which is the safe side for a floor. + if (!PathContainment.IsStrictlyWithin(fullPath, rootFull, StringComparison.Ordinal)) { return $"'{target}' escapes the project root."; } @@ -285,7 +290,7 @@ private static bool IsUnderAllowlist(string normalizedRel, IReadOnlyList private static bool TraversesReparsePoint(string targetFullPath, string rootFull) { var dir = Path.GetDirectoryName(targetFullPath); - while (dir != null && dir.Length > rootFull.Length && dir.StartsWith(rootFull, StringComparison.Ordinal)) + while (dir != null && PathContainment.IsStrictlyWithin(dir, rootFull, StringComparison.Ordinal)) { if (Directory.Exists(dir) && (File.GetAttributes(dir) & FileAttributes.ReparsePoint) != 0) { diff --git a/src/Ashlar.Hosting/Meai/MeaiVectorDataRagAdapter.cs b/src/Ashlar.Hosting/Meai/MeaiVectorDataRagAdapter.cs index 7913eca63..26e430e5d 100644 --- a/src/Ashlar.Hosting/Meai/MeaiVectorDataRagAdapter.cs +++ b/src/Ashlar.Hosting/Meai/MeaiVectorDataRagAdapter.cs @@ -7,6 +7,18 @@ namespace Ashlar.Hosting.Meai; /// Adapts to the legacy surface /// so tools/CLI keep working after Phase 6 cutover. /// +/// +/// This adapter no longer substitutes a tier for one the caller did not give, in either +/// direction, and both substitutions it used to make failed open. An omitted search clearance +/// became "TopSecret", so rag search without --max-sensitivity returned the whole +/// corpus, and the audit then recorded caller_tier=TopSecret as if the caller had been +/// cleared for it. An omitted index label became "Public", so an unclassified file was served to +/// everyone. +/// Both blanks now pass through to , which applies the +/// FLOOR to an omitted clearance and ranks an unlabelled record at the TOP (see +/// ), and whose audit names the clearance it actually applied and +/// why. +/// public sealed class MeaiVectorDataRagAdapter : IRAGService { private readonly VectorDataRagService _rag; @@ -23,10 +35,9 @@ public async Task> SearchAsync( string? maxSensitivityLevelName, CancellationToken cancellationToken = default) { - var tier = string.IsNullOrWhiteSpace(maxSensitivityLevelName) ? "TopSecret" : maxSensitivityLevelName.Trim(); var hits = await _rag.SearchAsync( query ?? string.Empty, - callerMaxTrustTier: tier, + callerMaxTrustTier: maxSensitivityLevelName, top: maxResults, minScore: minScore, cancellationToken: cancellationToken) @@ -37,7 +48,8 @@ public async Task> SearchAsync( h.Record.Key, h.Record.Text, h.Score ?? 0d, - h.Record.TrustTier)) + // IRAGService says null means "unmarked"; the store keeps an unlabelled chunk as "". + string.IsNullOrWhiteSpace(h.Record.TrustTier) ? null : h.Record.TrustTier)) .ToList(); } @@ -51,7 +63,7 @@ public Task IndexAsync( id, text ?? string.Empty, sourceUri: null, - trustTier: string.IsNullOrWhiteSpace(sensitivityLevelName) ? "Public" : sensitivityLevelName.Trim(), + trustTier: sensitivityLevelName, cancellationToken: cancellationToken); /// diff --git a/src/Ashlar.Hosting/Sdk/Builders/HostAshlarSdkBuilder.cs b/src/Ashlar.Hosting/Sdk/Builders/HostAshlarSdkBuilder.cs index 8e3898207..64d437bf7 100644 --- a/src/Ashlar.Hosting/Sdk/Builders/HostAshlarSdkBuilder.cs +++ b/src/Ashlar.Hosting/Sdk/Builders/HostAshlarSdkBuilder.cs @@ -4,7 +4,6 @@ using Ashlar.Infrastructure.Sdk.Ports; namespace Ashlar.Hosting.Sdk.Builders; -#pragma warning disable CS0618 // AshlarSdkBuilder is an obsolete type forwarder in this file /// /// Default implementation of . Configures for kernel registration. diff --git a/src/Ashlar.Infrastructure/Execution/Scratch/FileScratchSpace.cs b/src/Ashlar.Infrastructure/Execution/Scratch/FileScratchSpace.cs index a862b2dab..5b2e0ae8b 100644 --- a/src/Ashlar.Infrastructure/Execution/Scratch/FileScratchSpace.cs +++ b/src/Ashlar.Infrastructure/Execution/Scratch/FileScratchSpace.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Ashlar.Abstractions.Paths; using Ashlar.Core.Application.Execution.Ports; namespace Ashlar.Infrastructure.Execution.Scratch; @@ -66,11 +67,9 @@ public string ResolveUnderRoot(string relativeOrAbsolute) public bool IsUnderRoot(string path) { if (_root is null) return true; + // GetFullPath first, so an unresolvable path still throws here as it always has. var full = System.IO.Path.GetFullPath(path); - var root = _root.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar) - + System.IO.Path.DirectorySeparatorChar; - return full.StartsWith(root, StringComparison.OrdinalIgnoreCase) - || string.Equals(full, _root, StringComparison.OrdinalIgnoreCase); + return PathContainment.IsWithin(full, _root); } /// diff --git a/src/Ashlar.Infrastructure/Execution/Scratch/SnapshotManagedFileSet.cs b/src/Ashlar.Infrastructure/Execution/Scratch/SnapshotManagedFileSet.cs index 592858cd0..62ee80f4e 100644 --- a/src/Ashlar.Infrastructure/Execution/Scratch/SnapshotManagedFileSet.cs +++ b/src/Ashlar.Infrastructure/Execution/Scratch/SnapshotManagedFileSet.cs @@ -1,3 +1,4 @@ +using Ashlar.Abstractions.Paths; using Ashlar.Core.Application.Execution.Ports; namespace Ashlar.Infrastructure.Execution.Scratch; @@ -104,10 +105,7 @@ private static string NormalizeRel(string name) private static void EnsureUnderRoot(string root, string fullPath) { - var prefix = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - if (!fullPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - && !string.Equals(fullPath, root, StringComparison.OrdinalIgnoreCase)) + if (!PathContainment.IsWithin(fullPath, root)) { throw new InvalidOperationException($"managed file escapes destRoot: {fullPath}"); } diff --git a/src/Ashlar.Policies.Dev/PathAllowlist.cs b/src/Ashlar.Policies.Dev/PathAllowlist.cs index 867eae443..e20ff6d54 100644 --- a/src/Ashlar.Policies.Dev/PathAllowlist.cs +++ b/src/Ashlar.Policies.Dev/PathAllowlist.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text.Json; using Ashlar.Abstractions; +using Ashlar.Abstractions.Paths; using Ashlar.Core.Application.Paths; namespace Ashlar.Policies.Dev; @@ -162,7 +163,7 @@ public bool Approve(ToolCall call, WorldSnapshot s, out string reason) return false; } - if (!IsPathWithinRoot(fullPath, sandboxRoot)) + if (!PathContainment.IsWithin(fullPath, sandboxRoot)) { reason = $"Path not allowed: outside SandboxRoot: {fullPath}"; return false; @@ -238,16 +239,4 @@ private static string CollapseDotSegments(string prefix) return null; } } - - private static bool IsPathWithinRoot(string candidate, string root) - { - var normalizedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var normalizedCandidate = candidate; - - if (normalizedCandidate.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase)) - return true; - - var prefix = normalizedRoot + Path.DirectorySeparatorChar; - return normalizedCandidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); - } } diff --git a/src/Ashlar.Policies/OutputPathSandboxed.cs b/src/Ashlar.Policies/OutputPathSandboxed.cs index 5b271cd3e..af8ae86d2 100644 --- a/src/Ashlar.Policies/OutputPathSandboxed.cs +++ b/src/Ashlar.Policies/OutputPathSandboxed.cs @@ -1,16 +1,23 @@ using System.Text.Json; using Ashlar.Abstractions; +using Ashlar.Abstractions.Paths; namespace Ashlar.Policies; /// /// Policy that enforces output path sandboxing. -/// +/// /// Ensures that tool calls that write files only write within a specified sandbox directory. /// Prevents path traversal attacks and unauthorized file system access. -/// +/// /// Implements IPolicy for use with PolicyEngine. /// Checks for "output" property in tool call arguments and validates it's within the sandbox. +/// +/// Containment is : the output must be +/// the root itself or lie under the root FOLLOWED BY A SEPARATOR, so OutputRoot .../out no +/// longer admits the sibling .../out-evil, and letter case is compared the way the platform's +/// default file system does. The check is lexical: it does not follow symbolic links. A relative +/// output resolves against the process working directory, not against OutputRoot. /// public sealed class OutputPathSandboxed : IPolicy { @@ -25,12 +32,14 @@ public bool Approve(ToolCall call, WorldSnapshot s, out string reason) if (call.Arguments.TryGetProperty("output", out var outProp)) { - var output = outProp.GetString() ?? ""; try { + // Inside the try: GetString throws on a non-string "output", and a policy refuses what + // it cannot read rather than throwing out of Approve. + var output = outProp.GetString() ?? ""; var full = Path.GetFullPath(output); var baseDir = Path.GetFullPath(root); - if (!full.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase)) + if (!PathContainment.IsWithin(full, baseDir)) { reason = $"Output '{full}' escapes sandbox '{baseDir}'"; return false; diff --git a/src/Ashlar.Tests.AI.Pipeline/TrustTierOrderTests.cs b/src/Ashlar.Tests.AI.Pipeline/TrustTierOrderTests.cs new file mode 100644 index 000000000..60926b840 --- /dev/null +++ b/src/Ashlar.Tests.AI.Pipeline/TrustTierOrderTests.cs @@ -0,0 +1,95 @@ +using FluentAssertions; +using Ashlar.AI.Pipeline.Rag; +using Xunit; + +namespace Ashlar.Tests.AI.Pipeline; + +/// +/// A caller clearance and a record label fail closed in OPPOSITE directions. The single +/// Rank these replace applied one rule to both and was open both ways: a blank name ranked +/// Public (so an unlabelled record reached everyone) and an unknown name ranked TopSecret (so a +/// misspelt or "Unclassified" caller clearance reached everything). +/// +public sealed class TrustTierOrderTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Unclassified")] + [InlineData("Secrte")] + public void An_omitted_or_unrecognised_caller_clearance_ranks_at_the_floor(string? caller) + { + TrustTierOrder.CallerRank(caller).Should().Be(TrustTierOrder.CallerRank(TrustTierOrder.Floor)); + TrustTierOrder.CallerRank(caller).Should().Be(0); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Unclassified")] + [InlineData("Bogus")] + public void An_unlabelled_or_unrecognised_record_ranks_at_the_top(string? record) + { + TrustTierOrder.RecordRank(record).Should().Be(TrustTierOrder.RecordRank(TrustTierOrder.MostRestrictive)); + TrustTierOrder.RecordRank(record).Should().Be(4); + } + + // Positive control for both theories above: a KNOWN name ranks the same on both sides, so + // neither fallback can be satisfied by ranking everything at one end. + [Theory] + [InlineData("Public", 0)] + [InlineData("internal", 1)] + [InlineData(" Confidential ", 2)] + [InlineData("SECRET", 3)] + [InlineData("TopSecret", 4)] + [InlineData("top-secret", 4)] + public void A_known_tier_ranks_the_same_for_a_caller_and_a_record(string tier, int rank) + { + TrustTierOrder.CallerRank(tier).Should().Be(rank); + TrustTierOrder.RecordRank(tier).Should().Be(rank); + } + + [Theory] + [InlineData(null, "Public", "omitted")] + [InlineData(" ", "Public", "omitted")] + [InlineData("Unclassified", "Public", "unrecognised")] + [InlineData("secret", "Secret", "explicit")] + [InlineData("top-secret", "TopSecret", "explicit")] + public void ResolveCaller_names_the_clearance_applied_and_why(string? caller, string applied, string basis) + { + TrustTierOrder.ResolveCaller(caller).Should().Be((applied, basis)); + } + + [Theory] + // Unlabelled and unknown records: only the top clearance. + [InlineData(null, "TopSecret", true)] + [InlineData(null, "Secret", false)] + [InlineData("", null, false)] + [InlineData("Bogus", "Secret", false)] + [InlineData("Bogus", "TopSecret", true)] + // Omitted and unknown callers: only the floor. + [InlineData("Public", null, true)] + [InlineData("Internal", null, false)] + [InlineData("Secret", "Unclassified", false)] + [InlineData("Public", "Unclassified", true)] + // Known on both sides: the plain order. + [InlineData("Confidential", "Secret", true)] + [InlineData("Secret", "Confidential", false)] + public void IsAllowed_applies_each_fallback_to_its_own_side(string? record, string? caller, bool allowed) + { + TrustTierOrder.IsAllowed(record, caller).Should().Be(allowed); + } + + [Theory] + [InlineData(null, "")] + [InlineData(" ", "")] + [InlineData("secret", "Secret")] + [InlineData("top-secret", "TopSecret")] + [InlineData(" PartnerOnly ", "PartnerOnly")] + public void NormalizeRecordTier_keeps_unlabelled_unlabelled_and_invents_no_tier(string? input, string stored) + { + TrustTierOrder.NormalizeRecordTier(input).Should().Be(stored); + } +} diff --git a/src/Ashlar.Tests.AI.Pipeline/VectorDataRagFailClosedTests.cs b/src/Ashlar.Tests.AI.Pipeline/VectorDataRagFailClosedTests.cs new file mode 100644 index 000000000..e8df036fa --- /dev/null +++ b/src/Ashlar.Tests.AI.Pipeline/VectorDataRagFailClosedTests.cs @@ -0,0 +1,170 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Ashlar.AI.Pipeline; +using Ashlar.AI.Pipeline.Clients; +using Ashlar.AI.Pipeline.Governance; +using Ashlar.AI.Pipeline.Rag; +using Xunit; + +namespace Ashlar.Tests.AI.Pipeline; + +/// +/// VectorDataRagService is the store the shipped hosts search (AshlarKernelRegistrar phase 13b +/// points IRAGService at it through MeaiVectorDataRagAdapter), so its defaults ARE the product's +/// defaults: an omitted clearance searches at the floor, an unlabelled chunk is served only to the +/// top clearance, and re-indexing can never lower a chunk's tier. +/// +public sealed class VectorDataRagFailClosedTests +{ + private const string Text = "shared incident playbook guidance"; + + [Fact] + public async Task An_omitted_clearance_searches_at_the_floor_and_the_audit_says_so() + { + var auditor = new InMemoryChatInvocationAuditor(); + await using var provider = BuildProvider(auditor); + var rag = provider.GetRequiredService(); + await rag.IndexAsync("pub", Text, trustTier: "Public"); + await rag.IndexAsync("sec", Text, trustTier: "Secret"); + + var hits = await rag.SearchAsync("incident playbook", callerMaxTrustTier: null, top: 10); + + hits.Select(h => h.Record.Key).Should().BeEquivalentTo(new[] { "pub" }); + LastSearchDecisions(auditor).Should().Contain("caller_tier=Public") + .And.Contain("caller_tier_basis=omitted") + .And.NotContain(d => d.Contains("TopSecret"), "the audit must name the clearance applied, not a substitute"); + } + + [Fact] + public async Task An_unrecognised_clearance_searches_at_the_floor_and_is_audited_as_unrecognised() + { + var auditor = new InMemoryChatInvocationAuditor(); + await using var provider = BuildProvider(auditor); + var rag = provider.GetRequiredService(); + await rag.IndexAsync("pub", Text, trustTier: "Public"); + await rag.IndexAsync("sec", Text, trustTier: "Secret"); + + var hits = await rag.SearchAsync("incident playbook", callerMaxTrustTier: "Unclassified", top: 10); + + hits.Select(h => h.Record.Key).Should().BeEquivalentTo(new[] { "pub" }); + LastSearchDecisions(auditor).Should().Contain("caller_tier=Public") + .And.Contain("caller_tier_basis=unrecognised"); + } + + // POSITIVE CONTROL for the two above: an explicit clearance still reaches what it is cleared + // for, so "only Public came back" cannot be satisfied by a search that returns only Public. + [Fact] + public async Task An_explicit_clearance_reaches_what_it_is_cleared_for_and_is_audited_as_explicit() + { + var auditor = new InMemoryChatInvocationAuditor(); + await using var provider = BuildProvider(auditor); + var rag = provider.GetRequiredService(); + await rag.IndexAsync("pub", Text, trustTier: "Public"); + await rag.IndexAsync("sec", Text, trustTier: "Secret"); + + var hits = await rag.SearchAsync("incident playbook", callerMaxTrustTier: "secret", top: 10); + + hits.Select(h => h.Record.Key).Should().BeEquivalentTo(new[] { "pub", "sec" }); + LastSearchDecisions(auditor).Should().Contain("caller_tier=Secret") + .And.Contain("caller_tier_basis=explicit"); + } + + [Fact] + public async Task An_unlabelled_chunk_is_served_only_to_the_top_clearance() + { + await using var provider = BuildProvider(); + var rag = provider.GetRequiredService(); + await rag.IndexAsync("unlabelled", Text); + + (await rag.SearchAsync("incident playbook", "Public", top: 10)).Should().BeEmpty(); + (await rag.SearchAsync("incident playbook", "Secret", top: 10)).Should().BeEmpty(); + + var top = await rag.SearchAsync("incident playbook", "TopSecret", top: 10); + top.Should().ContainSingle().Which.Record.TrustTier.Should().BeEmpty("no tier is invented for an unlabelled chunk"); + } + + // A ChunkRecord built without a tier -- written straight into the collection, as a connector + // migration or a host-side import would -- is unlabelled, not Public. ChunkRecord.TrustTier + // used to default to "Public", which published any record whose writer forgot the field. + [Fact] + public async Task A_record_written_without_a_tier_is_unlabelled_not_Public() + { + await using var provider = BuildProvider(); + var rag = provider.GetRequiredService(); + var collection = provider.GetRequiredService>(); + var generator = provider.GetRequiredService>>(); + var embedding = (await generator.GenerateAsync(new[] { Text })).First().Vector; + + await collection.UpsertAsync(new ChunkRecord { Key = "imported", Text = Text, Embedding = embedding }); + + (await rag.SearchAsync("incident playbook", "Secret", top: 10)).Should().BeEmpty(); + (await rag.SearchAsync("incident playbook", "TopSecret", top: 10)) + .Should().ContainSingle().Which.Record.Key.Should().Be("imported"); + } + + [Fact] + public async Task Reindexing_a_key_at_a_lower_tier_is_refused_and_the_stored_tier_survives() + { + var auditor = new InMemoryChatInvocationAuditor(); + await using var provider = BuildProvider(auditor); + var rag = provider.GetRequiredService(); + await rag.IndexAsync("/kb/plan.md", Text, trustTier: "Secret"); + + var downgrade = async () => await rag.IndexAsync("/kb/plan.md", Text, trustTier: "Public"); + + (await downgrade.Should().ThrowAsync()) + .WithMessage("*Refusing to re-index*'/kb/plan.md'*'Public'*'Secret'*"); + (await rag.SearchAsync("incident playbook", "Public", top: 10)).Should().BeEmpty("the downgrade must not have been written"); + (await rag.SearchAsync("incident playbook", "Secret", top: 10)) + .Should().ContainSingle().Which.Record.TrustTier.Should().Be("Secret"); + auditor.Records.Should().Contain(r => + r.TargetKey == "rag:index" && r.Outcome == "denied" && r.PolicyDecisions.Contains("event=downgrade_refused")); + } + + [Fact] + public async Task Reindexing_an_unlabelled_key_with_a_real_label_is_a_downgrade_too() + { + await using var provider = BuildProvider(); + var rag = provider.GetRequiredService(); + await rag.IndexAsync("/kb/unlabelled.md", Text); + + var relabel = async () => await rag.IndexAsync("/kb/unlabelled.md", Text, trustTier: "Internal"); + + await relabel.Should().ThrowAsync().WithMessage("*(unlabelled)*"); + } + + // POSITIVE CONTROL for the refusals: re-indexing at the same or a HIGHER tier is accepted, so + // the refusal cannot be satisfied by refusing every re-index. + [Fact] + public async Task Reindexing_at_the_same_or_a_higher_tier_is_accepted() + { + await using var provider = BuildProvider(); + var rag = provider.GetRequiredService(); + await rag.IndexAsync("/kb/plan.md", Text, trustTier: "Internal"); + + await rag.IndexAsync("/kb/plan.md", Text, trustTier: "internal"); + await rag.IndexAsync("/kb/plan.md", Text, trustTier: "Secret"); + + (await rag.SearchAsync("incident playbook", "Internal", top: 10)).Should().BeEmpty(); + (await rag.SearchAsync("incident playbook", "Secret", top: 10)) + .Should().ContainSingle().Which.Record.TrustTier.Should().Be("Secret"); + } + + private static IReadOnlyList LastSearchDecisions(InMemoryChatInvocationAuditor auditor) => + auditor.Records.Last(r => r.TargetKey == "rag:search").PolicyDecisions; + + private static ServiceProvider BuildProvider(InMemoryChatInvocationAuditor? auditor = null) + { + var services = new ServiceCollection(); + if (auditor is not null) + { + services.AddSingleton(auditor); + } + + services.AddAshlarMeaiPipeline( + ollamaInnerFactory: _ => new FakeChatClient(), + onnxInnerFactory: _ => new FakeChatClient(), + registerDefaultRouter: false); + return services.BuildServiceProvider(); + } +} diff --git a/src/Ashlar.Tests.BackgroundAgents/Configuration/BackgroundAgentConfigLoaderGapCoverageTests.cs b/src/Ashlar.Tests.BackgroundAgents/Configuration/BackgroundAgentConfigLoaderGapCoverageTests.cs index 945f6e888..64016a303 100644 --- a/src/Ashlar.Tests.BackgroundAgents/Configuration/BackgroundAgentConfigLoaderGapCoverageTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/Configuration/BackgroundAgentConfigLoaderGapCoverageTests.cs @@ -1,9 +1,11 @@ using FluentAssertions; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Moq; using Ashlar.BackgroundAgents.Configuration; using Ashlar.BackgroundAgents.DataSensitivity; using System.Reflection; +using System.Text; using Xunit; namespace Ashlar.Tests.BackgroundAgents.Configuration; @@ -128,8 +130,89 @@ public async Task LoadAsync_ValidCronSchedule_ReturnsConfig() configs[0].Schedule.Type.Should().Be(ScheduleType.Cron); } + // REPLACES LoadAsync_AppliesDefaultExfiltrationPolicyWhenUnset, which was a tripwire: it passed + // only because it WROTE an ExfiltrationPolicy section with a whitespace MaxAllowedLevel, the one + // shape that reached the derivation. An agent that genuinely omits the section never did -- the + // default ExfiltrationPolicy's MaxAllowedLevel is "Public", never blank -- so a TopSecret agent + // with no section ran with every Block flag false. This one omits the section. [Fact] - public async Task LoadAsync_AppliesDefaultExfiltrationPolicyWhenUnset() + public async Task LoadAsync_WithNoExfiltrationPolicySection_DerivesTheRestrictionsItsLevelImplies() + { + var values = ValidAgentBase(); + values["BackgroundAgents:Agents:0:Schedule:Type"] = "Interval"; + values["BackgroundAgents:Agents:0:Schedule:Interval"] = "00:05:00"; + values["BackgroundAgents:Agents:0:MaxDataSensitivity"] = "TopSecret"; + + // Arrange-step guard: the section really is absent, so the derivation below is the + // loader's doing and not a key this test wrote. + values.Keys.Should().NotContain(k => k.Contains("ExfiltrationPolicy")); + + var configs = await CreateLoader(new ConfigurationBuilder().AddInMemoryCollection(values).Build()) + .LoadAsync(default); + + var policy = configs.Should().ContainSingle().Subject.ExfiltrationPolicy; + policy.MaxAllowedLevel.Should().Be("TopSecret"); + policy.BlockExternalLLMs.Should().BeTrue(); + policy.BlockWebSearch.Should().BeTrue(); + policy.BlockNetworkExports.Should().BeTrue(); + policy.RequireLocalOnly.Should().BeTrue(); + } + + // The derivation follows the level: Confidential blocks external LLMs and network exports but + // allows web search and is not local-only (DataSensitivityLevels.Confidential), so the result is + // the level's own flags and not "everything true". + [Fact] + public async Task LoadAsync_WithNoExfiltrationPolicySection_DerivesExactlyTheLevelsFlags() + { + var values = ValidAgentBase(); + values["BackgroundAgents:Agents:0:Schedule:Type"] = "Interval"; + values["BackgroundAgents:Agents:0:Schedule:Interval"] = "00:05:00"; + values["BackgroundAgents:Agents:0:MaxDataSensitivity"] = "Confidential"; + + var configs = await CreateLoader(new ConfigurationBuilder().AddInMemoryCollection(values).Build()) + .LoadAsync(default); + + var policy = configs[0].ExfiltrationPolicy; + policy.MaxAllowedLevel.Should().Be("Confidential"); + policy.BlockExternalLLMs.Should().BeTrue(); + policy.BlockNetworkExports.Should().BeTrue(); + policy.BlockWebSearch.Should().BeFalse(); + policy.RequireLocalOnly.Should().BeFalse(); + } + + // Each agent is judged on ITS OWN section: of two TopSecret agents, the one that writes a + // permissive section keeps it, and the one that writes none is derived. + [Fact] + public async Task LoadAsync_DetectsTheSectionPerAgent() + { + var values = new Dictionary(); + foreach (var (index, id) in new[] { (0, "explicit"), (1, "silent") }) + { + values[$"BackgroundAgents:Agents:{index}:Id"] = id; + values[$"BackgroundAgents:Agents:{index}:Name"] = id; + values[$"BackgroundAgents:Agents:{index}:Role"] = "monitor"; + values[$"BackgroundAgents:Agents:{index}:Commands:0"] = "ping"; + values[$"BackgroundAgents:Agents:{index}:Schedule:Type"] = "Continuous"; + values[$"BackgroundAgents:Agents:{index}:MaxDataSensitivity"] = "TopSecret"; + } + + values["BackgroundAgents:Agents:0:ExfiltrationPolicy:BlockWebSearch"] = "true"; + values["BackgroundAgents:Agents:0:ExfiltrationPolicy:MaxAllowedLevel"] = "TopSecret"; + + var configs = await CreateLoader(new ConfigurationBuilder().AddInMemoryCollection(values).Build()) + .LoadAsync(default); + + configs.Select(c => c.Id).Should().Equal("explicit", "silent"); + configs[0].ExfiltrationPolicy.BlockWebSearch.Should().BeTrue(); + configs[0].ExfiltrationPolicy.BlockExternalLLMs.Should().BeFalse("a written section is the operator's choice"); + configs[1].ExfiltrationPolicy.BlockExternalLLMs.Should().BeTrue(); + configs[1].ExfiltrationPolicy.RequireLocalOnly.Should().BeTrue(); + } + + // The old trigger still derives, so no configuration that used to be restricted loses it: a + // WRITTEN section whose four flags are false and whose MaxAllowedLevel is blank. + [Fact] + public async Task LoadAsync_WrittenSectionThatStatesNothing_StillDerives() { var values = ValidAgentBase(); values["BackgroundAgents:Agents:0:Schedule:Type"] = "Interval"; @@ -149,6 +232,147 @@ public async Task LoadAsync_AppliesDefaultExfiltrationPolicyWhenUnset() configs[0].ExfiltrationPolicy.BlockNetworkExports.Should().BeTrue(); } + // One agent whose configuration the binder cannot convert is LEFT OUT, and the agents next to it + // still load -- what section.Bind(list) did before the loader bound agents one at a time. The + // list bind swallowed the binder's exception; Get throws it, so without the loader's catch one + // mistyped value failed every LoadAsync caller (the daemon, background-agent list/show/execute, + // ConfigCommand, UpdateAgentConfigTool). The drop is now logged with the element's path, where it + // used to be silent. The third row is a malformed EXFILTRATION flag on a TopSecret agent: that + // agent does not run at all, which is the closed outcome. + [Theory] + [InlineData("Enabled", "notabool")] + [InlineData("Schedule:Type", "Weekly")] + [InlineData("ExfiltrationPolicy:BlockExternalLLMs", "yes")] + public async Task LoadAsync_AnAgentThatCannotBeBound_IsLeftOutWithAWarning_AndTheOthersLoad(string key, string badValue) + { + var values = TwoAgents("bad", "good"); + values["BackgroundAgents:Agents:0:MaxDataSensitivity"] = "TopSecret"; + values[$"BackgroundAgents:Agents:0:{key}"] = badValue; + var logger = new ListLogger(); + + var configs = await new BackgroundAgentConfigLoader( + new ConfigurationBuilder().AddInMemoryCollection(values).Build(), + new DataSensitivityRegistry(), + logger) + .LoadAsync(default); + + configs.Select(c => c.Id).Should().Equal("good"); + logger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Warning) + .Which.Message.Should().Contain("BackgroundAgents:Agents:0").And.Contain(badValue); + } + + // POSITIVE CONTROL for the row above: the same two agents with nothing malformed both load and + // nothing is logged, so the drop above is the bad value's doing. + [Fact] + public async Task LoadAsync_TwoWellFormedAgents_BothLoad_AndNothingIsLogged() + { + var logger = new ListLogger(); + + var configs = await new BackgroundAgentConfigLoader( + new ConfigurationBuilder().AddInMemoryCollection(TwoAgents("bad", "good")).Build(), + new DataSensitivityRegistry(), + logger) + .LoadAsync(default); + + configs.Select(c => c.Id).Should().Equal("bad", "good"); + logger.Entries.Should().BeEmpty(); + } + + // A SCALAR where an agent object belongs binds to nothing; the list bind left it out and the + // other agents loaded. Still so, and now logged. + [Theory] + [InlineData("json-string")] + [InlineData("memory-value")] + public async Task LoadAsync_AScalarWhereAnAgentBelongs_IsLeftOutWithAWarning(string shape) + { + var logger = new ListLogger(); + + var configs = await new BackgroundAgentConfigLoader(FirstAgentElementThenAGoodOne(shape), new DataSensitivityRegistry(), logger) + .LoadAsync(default); + + configs.Select(c => c.Id).Should().Equal("good"); + logger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Warning) + .Which.Message.Should().Contain("BackgroundAgents:Agents:0").And.Contain("not-an-agent"); + } + + // An EMPTY element is NOT an element that binds to nothing, measured: section.Bind(list) bound + // {} or null to a DEFAULT BackgroundAgentConfig, which ValidateConfig refuses, so the whole load + // failed with "Agent ID is required". Get returns null for it instead; skipping that null -- + // what the per-agent loop first did -- quietly turned the refusal into a pass. Still refused. + [Theory] + [InlineData("json-empty-object")] + [InlineData("json-null")] + [InlineData("memory-empty")] + [InlineData("memory-null")] + public async Task LoadAsync_AnEmptyAgentElement_IsRefused_AsTheListBindRefusedIt(string shape) + { + var loader = new BackgroundAgentConfigLoader(FirstAgentElementThenAGoodOne(shape), new DataSensitivityRegistry()); + + var act = () => loader.LoadAsync(default); + + await act.Should().ThrowAsync().WithMessage("Agent ID is required"); + } + + private const string GoodAgentJson = + """{"Id":"good","Name":"good","Role":"monitor","Commands":["ping"],"Schedule":{"Type":"Continuous"},"MaxDataSensitivity":"Public"}"""; + + // Agents[0] is the shape under test and Agents[1] a well-formed agent "good". + private static IConfiguration FirstAgentElementThenAGoodOne(string shape) + { + string? json = shape switch + { + "json-string" => "\"not-an-agent\"", + "json-empty-object" => "{}", + "json-null" => "null", + _ => null, + }; + if (json != null) + { + var document = "{\"BackgroundAgents\":{\"Agents\":[" + json + "," + GoodAgentJson + "]}}"; + return new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(document))) + .Build(); + } + + var values = TwoAgents("placeholder", "good"); + foreach (var key in values.Keys.Where(k => k.StartsWith("BackgroundAgents:Agents:0:", StringComparison.Ordinal)).ToList()) + values.Remove(key); + values["BackgroundAgents:Agents:0"] = shape switch + { + "memory-value" => "not-an-agent", + "memory-empty" => "", + "memory-null" => null, + _ => throw new ArgumentOutOfRangeException(nameof(shape), shape, "unknown shape"), + }; + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + private static Dictionary TwoAgents(string first, string second) + { + var values = new Dictionary(); + foreach (var (index, id) in new[] { (0, first), (1, second) }) + { + values[$"BackgroundAgents:Agents:{index}:Id"] = id; + values[$"BackgroundAgents:Agents:{index}:Name"] = id; + values[$"BackgroundAgents:Agents:{index}:Role"] = "monitor"; + values[$"BackgroundAgents:Agents:{index}:Commands:0"] = "ping"; + values[$"BackgroundAgents:Agents:{index}:Schedule:Type"] = "Continuous"; + values[$"BackgroundAgents:Agents:{index}:MaxDataSensitivity"] = "Public"; + } + + return values; + } + + private sealed class ListLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Entries.Add((logLevel, formatter(state, exception))); + private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } + } + [Fact] public async Task LoadAsync_PreservesExplicitExfiltrationPolicy() { @@ -252,7 +476,7 @@ public void ProcessConfig_throws_when_sensitivity_level_disappears() "ProcessConfig", BindingFlags.Instance | BindingFlags.NonPublic)!; - var act = () => process.Invoke(loader, new object[] { config }); + var act = () => process.Invoke(loader, new object[] { config, false }); act.Should().Throw() .WithInnerException() .WithMessage("*Unknown sensitivity level*"); diff --git a/src/Ashlar.Tests.BackgroundAgents/DataSensitivity/DataSensitivityMarkerTests.cs b/src/Ashlar.Tests.BackgroundAgents/DataSensitivity/DataSensitivityMarkerTests.cs index 25025cbb1..28f74281e 100644 --- a/src/Ashlar.Tests.BackgroundAgents/DataSensitivity/DataSensitivityMarkerTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/DataSensitivity/DataSensitivityMarkerTests.cs @@ -9,8 +9,10 @@ namespace Ashlar.Tests.BackgroundAgents.DataSensitivity; /// public class DataSensitivityMarkerTests { + // INVERTED. This used to be GetSensitivityLevel_WithUnmarkedData_ReturnsPublic and pinned the + // fail-open default as the contract: data nobody classified was reported as Public. [Fact] - public void GetSensitivityLevel_WithUnmarkedData_ReturnsPublic() + public void GetSensitivityLevel_WithUnmarkedData_ReturnsTheMostRestrictiveLevel() { // Arrange var registry = new DataSensitivityRegistry(); @@ -21,7 +23,22 @@ public void GetSensitivityLevel_WithUnmarkedData_ReturnsPublic() var level = marker.GetSensitivityLevel(data); // Assert - level.Should().Be(DataSensitivityLevels.Public); + level.Should().Be(DataSensitivityLevels.TopSecret); + marker.GetSensitivityLevel(null!).Should().Be(DataSensitivityLevels.TopSecret, "null data is unmarked too"); + } + + // "Most restrictive" is the registry's highest level, not a fixed name: a custom level + // registered above TopSecret is where unmarked data lands. + [Fact] + public void GetSensitivityLevel_WithUnmarkedData_FollowsACustomLevelAboveTopSecret() + { + var registry = new DataSensitivityRegistry(); + var codeword = new ConfigurableSensitivityLevel("Codeword", "Codeword", 10, false, false, true, false, "above TopSecret"); + registry.Register(codeword); + var marker = new DataSensitivityMarker(registry); + + marker.GetSensitivityLevel(new { Value = "test" }).Should().Be(codeword); + marker.CanAccess(DataSensitivityLevels.TopSecret, new { Value = "test" }).Should().BeFalse(); } [Fact] @@ -102,8 +119,10 @@ public void CanAccess_WithInaccessibleData_ReturnsFalse() canAccess.Should().BeFalse(); } + // INVERTED. This used to be CanAccess_WithUnmarkedData_ReturnsTrue: an Internal agent could read + // anything nobody had marked, because unmarked meant Public. [Fact] - public void CanAccess_WithUnmarkedData_ReturnsTrue() + public void CanAccess_WithUnmarkedData_IsDeniedBelowTheTopLevel() { // Arrange var registry = new DataSensitivityRegistry(); @@ -114,6 +133,10 @@ public void CanAccess_WithUnmarkedData_ReturnsTrue() var canAccess = marker.CanAccess(DataSensitivityLevels.Internal, data); // Assert - canAccess.Should().BeTrue(); // Unmarked data defaults to Public, which is accessible + canAccess.Should().BeFalse("unmarked data is the most restrictive level, not Public"); + + // POSITIVE CONTROL: the top level still reads it, so the denial is the ordering and not a + // marker that denies everything. + marker.CanAccess(DataSensitivityLevels.TopSecret, data).Should().BeTrue(); } } diff --git a/src/Ashlar.Tests.BackgroundAgents/RAG/InMemoryVectorStoreTests.cs b/src/Ashlar.Tests.BackgroundAgents/RAG/InMemoryVectorStoreTests.cs index da617b8dc..48c9545c2 100644 --- a/src/Ashlar.Tests.BackgroundAgents/RAG/InMemoryVectorStoreTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/RAG/InMemoryVectorStoreTests.cs @@ -14,7 +14,7 @@ public async Task IndexAsync_And_SearchAsync_ReturnsMatchingDocument() var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(32); var emb = await gen.GenerateAsync("hello world", default); - await store.IndexAsync("doc1", "hello world", emb, null, default); + await store.IndexAsync("doc1", "hello world", emb, "Public", default); var queryEmb = await gen.GenerateAsync("hello world", default); var results = await store.SearchAsync(queryEmb, 5, 0.0, null, default); @@ -30,8 +30,8 @@ public async Task SearchAsync_WithMinScore_FiltersByScore() { var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(32); - await store.IndexAsync("doc1", "alpha beta", await gen.GenerateAsync("alpha beta", default), null, default); - await store.IndexAsync("doc2", "gamma delta", await gen.GenerateAsync("gamma delta", default), null, default); + await store.IndexAsync("doc1", "alpha beta", await gen.GenerateAsync("alpha beta", default), "Public", default); + await store.IndexAsync("doc2", "gamma delta", await gen.GenerateAsync("gamma delta", default), "Public", default); var queryEmb = await gen.GenerateAsync("alpha", default); var results = await store.SearchAsync(queryEmb, 5, 0.99, null, default); @@ -44,7 +44,7 @@ public async Task RemoveAsync_RemovesDocument() { var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(32); - await store.IndexAsync("doc1", "text", await gen.GenerateAsync("text", default), null, default); + await store.IndexAsync("doc1", "text", await gen.GenerateAsync("text", default), "Public", default); await store.RemoveAsync("doc1", default); var results = await store.SearchAsync(await gen.GenerateAsync("text", default), 5, 0.0, null, default); @@ -56,7 +56,7 @@ public async Task ClearAsync_RemovesAll() { var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(32); - await store.IndexAsync("doc1", "a", await gen.GenerateAsync("a", default), null, default); + await store.IndexAsync("doc1", "a", await gen.GenerateAsync("a", default), "Public", default); await store.ClearAsync(default); var results = await store.SearchAsync(await gen.GenerateAsync("a", default), 5, 0.0, null, default); @@ -99,7 +99,7 @@ await store.IndexAsync( "stale-doc", "hello world", await indexedGen.GenerateAsync("hello world", default), - null, + "Public", default); var queryGen = new TokenEmbeddingGenerator(32); @@ -125,7 +125,7 @@ public async Task SearchAsync_ZeroNormQuery_IsRefused_WhileRealQueriesStillAnswe var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(64); foreach (var (id, text) in new[] { ("a", "alpha beta gamma"), ("b", "gamma delta epsilon"), ("c", "zeta eta theta") }) - await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), null, default); + await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), "Public", default); var unrankable = await gen.GenerateAsync("!!!", default); var refuse = async () => await store.SearchAsync(unrankable, 5, 0.0, null, default); @@ -152,7 +152,7 @@ public async Task SearchAsync_Float32UnderflowQuery_IsRefusedToo() { var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(64); - await store.IndexAsync("a", "alpha beta gamma", await gen.GenerateAsync("alpha beta gamma", default), null, default); + await store.IndexAsync("a", "alpha beta gamma", await gen.GenerateAsync("alpha beta gamma", default), "Public", default); var tiny = new float[64]; Array.Fill(tiny, 1e-23f); @@ -174,8 +174,8 @@ public async Task SearchAsync_ZeroNormDocument_IsSkippedWhileTheRestOfTheQuerySt { var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(64); - await store.IndexAsync("real", "gamma delta epsilon", await gen.GenerateAsync("gamma delta epsilon", default), null, default); - await store.IndexAsync("empty-file.log", "", await gen.GenerateAsync("", default), null, default); + await store.IndexAsync("real", "gamma delta epsilon", await gen.GenerateAsync("gamma delta epsilon", default), "Public", default); + await store.IndexAsync("empty-file.log", "", await gen.GenerateAsync("", default), "Public", default); var results = await store.SearchAsync(await gen.GenerateAsync("gamma delta epsilon", default), 5, 0.0, null, default); @@ -192,7 +192,7 @@ public async Task SearchAsync_GenuinelyOrthogonalDocument_IsStillReturnedAtScore var store = new InMemoryVectorStore(); var doc = new float[4] { 0f, 1f, 0f, 0f }; var query = new float[4] { 1f, 0f, 0f, 0f }; - await store.IndexAsync("orthogonal", "unrelated but real", doc, null, default); + await store.IndexAsync("orthogonal", "unrelated but real", doc, "Public", default); var results = await store.SearchAsync(query, 5, 0.0, null, default); @@ -224,7 +224,7 @@ public async Task SearchAsync_OverflowingQuery_IsRefused_NotAnsweredWithEveryDoc var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(64); foreach (var (id, text) in new[] { ("a", "alpha beta gamma"), ("b", "gamma delta epsilon") }) - await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), null, default); + await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), "Public", default); var refuse = async () => await store.SearchAsync(Filled(64, 1e20f), 5, 0.0, null, default); await refuse.Should().ThrowAsync(); @@ -234,7 +234,7 @@ public async Task SearchAsync_OverflowingQuery_IsRefused_NotAnsweredWithEveryDoc var unit = new float[64]; unit[0] = 1f; var large = new InMemoryVectorStore(); - await large.IndexAsync("real", "real document", unit, null, default); + await large.IndexAsync("real", "real document", unit, "Public", default); (await large.SearchAsync(Filled(64, 1e19f), 5, 0.0, null, default)).Should().ContainSingle(); } @@ -252,7 +252,7 @@ public async Task SearchAsync_NaNQuery_IsRefused_NotAnsweredWithAnEmptyResult() var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(64); foreach (var (id, text) in new[] { ("a", "alpha beta gamma"), ("b", "gamma delta epsilon") }) - await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), null, default); + await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), "Public", default); var refuse = async () => await store.SearchAsync(Filled(64, float.NaN), 5, 0.0, null, default); await refuse.Should().ThrowAsync(); @@ -271,9 +271,9 @@ public async Task SearchAsync_NonFiniteDocument_IsSkippedWhileTheRestOfTheQueryS { var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(64); - await store.IndexAsync("real", "alpha beta gamma", await gen.GenerateAsync("alpha beta gamma", default), null, default); - await store.IndexAsync("nan", "malformed", Filled(64, float.NaN), null, default); - await store.IndexAsync("overflow", "malformed", Filled(64, 1e20f), null, default); + await store.IndexAsync("real", "alpha beta gamma", await gen.GenerateAsync("alpha beta gamma", default), "Public", default); + await store.IndexAsync("nan", "malformed", Filled(64, float.NaN), "Public", default); + await store.IndexAsync("overflow", "malformed", Filled(64, 1e20f), "Public", default); var results = await store.SearchAsync(await gen.GenerateAsync("alpha beta gamma", default), 5, 0.0, null, default); diff --git a/src/Ashlar.Tests.BackgroundAgents/RAG/KnowledgeBaseIndexerTests.cs b/src/Ashlar.Tests.BackgroundAgents/RAG/KnowledgeBaseIndexerTests.cs index 1f60c8525..b8d85c38f 100644 --- a/src/Ashlar.Tests.BackgroundAgents/RAG/KnowledgeBaseIndexerTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/RAG/KnowledgeBaseIndexerTests.cs @@ -34,7 +34,7 @@ public async Task IndexDocumentsAsync_IndexesFile_AndSearchFindsIt() var service = new RAGService(store, gen); var indexer = new KnowledgeBaseIndexer(service); - var count = await indexer.IndexDocumentsAsync(new[] { _tempFile }, null, default); + var count = await indexer.IndexDocumentsAsync(new[] { _tempFile }, "Public", default); count.Should().Be(1); var results = await service.SearchAsync("RAG", 5, 0.0, null, default); diff --git a/src/Ashlar.Tests.BackgroundAgents/RAG/LegacyVectorStoreFailClosedTests.cs b/src/Ashlar.Tests.BackgroundAgents/RAG/LegacyVectorStoreFailClosedTests.cs new file mode 100644 index 000000000..a0ed61476 --- /dev/null +++ b/src/Ashlar.Tests.BackgroundAgents/RAG/LegacyVectorStoreFailClosedTests.cs @@ -0,0 +1,154 @@ +using FluentAssertions; +using Ashlar.BackgroundAgents.DataSensitivity; +using Ashlar.BackgroundAgents.RAG; +using Xunit; + +namespace Ashlar.Tests.BackgroundAgents.RAG; + +/// +/// The two legacy stores fail closed, and identically. Before this, both applied NO sensitivity +/// filter unless a registry was supplied AND the clearance resolved, and both returned every +/// unmarked document to every caller -- so the common construction, new InMemoryVectorStore() +/// searched with a null clearance, served everything. +/// +/// Every store here is built WITHOUT a registry unless the test says otherwise, because that is the +/// construction that used to switch filtering off. +/// +public sealed class LegacyVectorStoreFailClosedTests +{ + private const string Text = "shared incident playbook guidance"; + + public static TheoryData Stores => new() { "memory", "sqlite" }; + + [Theory] + [MemberData(nameof(Stores))] + public async Task An_omitted_clearance_is_the_floor_not_everything(string kind) + { + await using var store = await Seed(kind, null, ("pub", "Public"), ("sec", "Secret")); + + (await Ids(store.Value, null)).Should().BeEquivalentTo(new[] { "pub" }); + (await Ids(store.Value, " ")).Should().BeEquivalentTo(new[] { "pub" }); + + // POSITIVE CONTROL: the Secret document is there, and reachable at its clearance. + (await Ids(store.Value, "Secret")).Should().BeEquivalentTo(new[] { "pub", "sec" }); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task An_unrecognised_clearance_is_the_floor_not_everything(string kind) + { + await using var store = await Seed(kind, null, ("pub", "Public"), ("sec", "Secret")); + + (await Ids(store.Value, "Unclassified")).Should().BeEquivalentTo(new[] { "pub" }); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task An_unmarked_document_is_served_only_to_the_top_clearance(string kind) + { + await using var store = await Seed(kind, null, ("pub", "Public"), ("unmarked", null)); + + (await Ids(store.Value, null)).Should().BeEquivalentTo(new[] { "pub" }); + (await Ids(store.Value, "Secret")).Should().BeEquivalentTo(new[] { "pub" }); + + var top = await store.Value.SearchAsync(await Embed(), 10, 0.0, "TopSecret", default); + top.Select(r => r.Id).Should().BeEquivalentTo(new[] { "pub", "unmarked" }); + top.Single(r => r.Id == "unmarked").SensitivityLevelName.Should().BeNull("no label is invented for an unmarked document"); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task A_document_with_an_unknown_label_is_served_only_to_the_top_clearance(string kind) + { + await using var store = await Seed(kind, null, ("pub", "Public"), ("odd", "Bogus")); + + (await Ids(store.Value, "Secret")).Should().BeEquivalentTo(new[] { "pub" }); + (await Ids(store.Value, "TopSecret")).Should().BeEquivalentTo(new[] { "pub", "odd" }); + } + + // With a custom level registered ABOVE TopSecret, "most restrictive" is that level, not + // TopSecret: an unmarked document is out of reach of a TopSecret caller too. + [Theory] + [MemberData(nameof(Stores))] + public async Task The_most_restrictive_level_is_the_registrys_highest_not_a_fixed_name(string kind) + { + var registry = new DataSensitivityRegistry(); + registry.Register(new ConfigurableSensitivityLevel("Codeword", "Codeword", 10, false, false, true, false, "above TopSecret")); + await using var store = await Seed(kind, registry, ("unmarked", null)); + + (await Ids(store.Value, "TopSecret")).Should().BeEmpty(); + (await Ids(store.Value, "Codeword")).Should().BeEquivalentTo(new[] { "unmarked" }); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task Reindexing_an_id_at_a_lower_label_is_refused_and_the_stored_label_survives(string kind) + { + await using var store = await Seed(kind, null, ("/kb/plan.md", "Secret")); + + var downgrade = async () => await store.Value.IndexAsync("/kb/plan.md", Text, await Embed(), "Public", default); + + (await downgrade.Should().ThrowAsync()) + .WithMessage("*Refusing to re-index*'/kb/plan.md'*'Public'*'Secret'*"); + (await Ids(store.Value, "Public")).Should().BeEmpty("the downgrade must not have been written"); + (await store.Value.SearchAsync(await Embed(), 10, 0.0, "Secret", default)) + .Should().ContainSingle().Which.SensitivityLevelName.Should().Be("Secret"); + } + + [Theory] + [MemberData(nameof(Stores))] + public async Task Reindexing_an_unmarked_id_with_a_real_label_is_a_downgrade_too(string kind) + { + await using var store = await Seed(kind, null, ("/kb/unmarked.md", null)); + + var relabel = async () => await store.Value.IndexAsync("/kb/unmarked.md", Text, await Embed(), "Internal", default); + + await relabel.Should().ThrowAsync().WithMessage("*(unmarked)*"); + } + + // POSITIVE CONTROL for the two refusals: the same or a HIGHER label is accepted. + [Theory] + [MemberData(nameof(Stores))] + public async Task Reindexing_at_the_same_or_a_higher_label_is_accepted(string kind) + { + await using var store = await Seed(kind, null, ("/kb/plan.md", "Internal")); + + await store.Value.IndexAsync("/kb/plan.md", Text, await Embed(), "internal", default); + await store.Value.IndexAsync("/kb/plan.md", Text, await Embed(), "Secret", default); + + (await Ids(store.Value, "Internal")).Should().BeEmpty(); + (await store.Value.SearchAsync(await Embed(), 10, 0.0, "Secret", default)) + .Should().ContainSingle().Which.SensitivityLevelName.Should().Be("Secret"); + } + + private static async Task Embed() => await new TokenEmbeddingGenerator(32).GenerateAsync(Text, default); + + private static async Task> Ids(IVectorStore store, string? clearance) => + (await store.SearchAsync(await Embed(), 10, 0.0, clearance, default)).Select(r => r.Id).ToList(); + + private static async Task Seed(string kind, IDataSensitivityRegistry? registry, params (string Id, string? Label)[] docs) + { + IVectorStore store = kind switch + { + "memory" => new InMemoryVectorStore(registry), + "sqlite" => new SqliteVectorStore( + Path.Combine(Path.GetTempPath(), $"rag_failclosed_{Guid.NewGuid():N}.db"), registry), + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; + + foreach (var (id, label) in docs) + await store.IndexAsync(id, Text, await Embed(), label, default); + + // Arrange-step guard: every document went in, so an empty search below is the filter + // talking and not a failed write. + (await store.GetDocumentCountAsync(default)).Should().Be(docs.Length); + return new StoreHandle(store); + } + + private sealed class StoreHandle(IVectorStore value) : IAsyncDisposable + { + public IVectorStore Value { get; } = value; + + public ValueTask DisposeAsync() => Value is IAsyncDisposable d ? d.DisposeAsync() : ValueTask.CompletedTask; + } +} diff --git a/src/Ashlar.Tests.BackgroundAgents/RAG/RAGServiceTests.cs b/src/Ashlar.Tests.BackgroundAgents/RAG/RAGServiceTests.cs index 2a981ad85..2a87fd015 100644 --- a/src/Ashlar.Tests.BackgroundAgents/RAG/RAGServiceTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/RAG/RAGServiceTests.cs @@ -15,7 +15,7 @@ public async Task IndexAsync_And_SearchAsync_ReturnsResults() var gen = new TokenEmbeddingGenerator(32); var service = new RAGService(store, gen); - await service.IndexAsync("id1", "Ashlar is a game framework", null, default); + await service.IndexAsync("id1", "Ashlar is a game framework", "Public", default); var results = await service.SearchAsync("Ashlar framework", 5, 0.0, null, default); results.Should().HaveCount(1); @@ -29,7 +29,7 @@ public async Task ClearAsync_RemovesAll() var store = new InMemoryVectorStore(); var gen = new TokenEmbeddingGenerator(32); var service = new RAGService(store, gen); - await service.IndexAsync("id1", "content", null, default); + await service.IndexAsync("id1", "content", "Public", default); await service.ClearAsync(default); var results = await service.SearchAsync("content", 5, 0.0, null, default); diff --git a/src/Ashlar.Tests.BackgroundAgents/RAG/RAGToolTests.cs b/src/Ashlar.Tests.BackgroundAgents/RAG/RAGToolTests.cs index a191ec45d..7ac658173 100644 --- a/src/Ashlar.Tests.BackgroundAgents/RAG/RAGToolTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/RAG/RAGToolTests.cs @@ -43,6 +43,36 @@ public async Task InvokeAsync_CallsRAGService_AndReturnsPayload() service.Verify(s => s.SearchAsync("test query", 5, 0.7, null, It.IsAny()), Times.Once); } + // The model cannot raise its own clearance. The agent's clearance comes from the snapshot; the + // model's maxSensitivityLevelName may only narrow it. It used to be the other way round -- the + // model's value won -- so a prompt injection that got the model to ask for TopSecret got it. + [Theory] + // agent clearance, model request, clearance searched at + [InlineData("Internal", "TopSecret", "Internal")] + [InlineData("Internal", "Bogus", "Internal")] + [InlineData("Internal", null, "Internal")] + [InlineData("Secret", "Public", "Public")] + [InlineData(null, "TopSecret", null)] + [InlineData(null, null, null)] + public async Task InvokeAsync_TheModelCanNarrowButNeverRaiseTheAgentsClearance( + string? agentClearance, string? requested, string? searchedAt) + { + var service = new Mock(); + service.Setup(s => s.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + var tool = new RAGTool(service.Object); + var args = requested is null + ? JsonSerializer.SerializeToElement(new { query = "plan" }) + : JsonSerializer.SerializeToElement(new { query = "plan", maxSensitivityLevelName = requested }); + var data = new Dictionary(); + if (agentClearance is not null) + data["maxDataSensitivity"] = agentClearance; + + await tool.InvokeAsync(new ToolCall("rag_search", args), new WorldSnapshot(0, data), default); + + service.Verify(s => s.SearchAsync("plan", 5, 0.7, searchedAt, It.IsAny()), Times.Once); + } + // This tool is the caller that had to change with the stores. The query text arrives in the // model's own JSON arguments and is never validated, minScore likewise -- a model-supplied 0 // overrides the safe default of 0.7 -- and the stores now REFUSE a query whose embedding has diff --git a/src/Ashlar.Tests.BackgroundAgents/RAG/SqliteVectorStoreTests.cs b/src/Ashlar.Tests.BackgroundAgents/RAG/SqliteVectorStoreTests.cs index 38640f0eb..728e6b758 100644 --- a/src/Ashlar.Tests.BackgroundAgents/RAG/SqliteVectorStoreTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/RAG/SqliteVectorStoreTests.cs @@ -14,7 +14,7 @@ public async Task IndexAsync_And_SearchAsync_ReturnsMatchingDocument() await using var store = new SqliteVectorStore(path); var gen = new TokenEmbeddingGenerator(32); var emb = await gen.GenerateAsync("hello sqlite", default); - await store.IndexAsync("doc1", "hello sqlite", emb, null, default); + await store.IndexAsync("doc1", "hello sqlite", emb, "Public", default); var queryEmb = await gen.GenerateAsync("hello sqlite", default); var results = await store.SearchAsync(queryEmb, 5, 0.0, null, default); @@ -30,7 +30,7 @@ public async Task RemoveAsync_RemovesDocument() var path = Path.Combine(Path.GetTempPath(), $"rag_test_{Guid.NewGuid():N}.db"); await using var store = new SqliteVectorStore(path); var gen = new TokenEmbeddingGenerator(32); - await store.IndexAsync("doc1", "text", await gen.GenerateAsync("text", default), null, default); + await store.IndexAsync("doc1", "text", await gen.GenerateAsync("text", default), "Public", default); await store.RemoveAsync("doc1", default); var results = await store.SearchAsync(await gen.GenerateAsync("text", default), 5, 0.0, null, default); @@ -58,7 +58,7 @@ await store.IndexAsync( "stale-doc", "hello sqlite", await indexedGen.GenerateAsync("hello sqlite", default), - null, + "Public", default); var queryGen = new TokenEmbeddingGenerator(32); @@ -79,7 +79,7 @@ public async Task SearchAsync_ZeroNormQuery_IsRefused_WhileRealQueriesStillAnswe await using var store = new SqliteVectorStore(path); var gen = new TokenEmbeddingGenerator(64); foreach (var (id, text) in new[] { ("a", "alpha beta gamma"), ("b", "gamma delta epsilon") }) - await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), null, default); + await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), "Public", default); var unrankable = await gen.GenerateAsync("!!!", default); var refuse = async () => await store.SearchAsync(unrankable, 5, 0.0, null, default); @@ -100,8 +100,8 @@ public async Task SearchAsync_ZeroNormRow_IsSkippedWhileTheRestOfTheQueryStillAn var path = Path.Combine(Path.GetTempPath(), $"rag_test_{Guid.NewGuid():N}.db"); await using var store = new SqliteVectorStore(path); var gen = new TokenEmbeddingGenerator(64); - await store.IndexAsync("real", "alpha beta gamma", await gen.GenerateAsync("alpha beta gamma", default), null, default); - await store.IndexAsync("empty", "", await gen.GenerateAsync("", default), null, default); + await store.IndexAsync("real", "alpha beta gamma", await gen.GenerateAsync("alpha beta gamma", default), "Public", default); + await store.IndexAsync("empty", "", await gen.GenerateAsync("", default), "Public", default); var results = await store.SearchAsync(await gen.GenerateAsync("alpha beta gamma", default), 5, 0.0, null, default); @@ -119,7 +119,7 @@ public async Task DisposeAsync_MakesFurtherUseAnError() var store = new SqliteVectorStore(path); var gen = new TokenEmbeddingGenerator(32); var emb = await gen.GenerateAsync("hello sqlite", default); - await store.IndexAsync("doc1", "hello sqlite", emb, null, default); + await store.IndexAsync("doc1", "hello sqlite", emb, "Public", default); // POSITIVE CONTROL: the store works before disposal, so what follows is about disposal // and not about a store that never worked. @@ -130,7 +130,7 @@ public async Task DisposeAsync_MakesFurtherUseAnError() var search = async () => await store.SearchAsync(emb, 5, 0.0, null, default); await search.Should().ThrowAsync(); - var index = async () => await store.IndexAsync("doc2", "more", emb, null, default); + var index = async () => await store.IndexAsync("doc2", "more", emb, "Public", default); await index.Should().ThrowAsync(); var count = async () => await store.GetDocumentCountAsync(default); @@ -155,7 +155,7 @@ public async Task DisposeAsync_BeforeFirstUse_ReportsUseAfterDispose_NotAMissing var gen = new TokenEmbeddingGenerator(32); var emb = await gen.GenerateAsync("hello sqlite", default); - var index = async () => await store.IndexAsync("doc1", "hello sqlite", emb, null, default); + var index = async () => await store.IndexAsync("doc1", "hello sqlite", emb, "Public", default); var thrown = await index.Should().ThrowAsync(); thrown.And.Message.Should().NotContain( @@ -168,7 +168,7 @@ public async Task DisposeAsync_IsIdempotent() { var path = Path.Combine(Path.GetTempPath(), $"rag_test_{Guid.NewGuid():N}.db"); var store = new SqliteVectorStore(path); - await store.IndexAsync("doc1", "t", await new TokenEmbeddingGenerator(32).GenerateAsync("t", default), null, default); + await store.IndexAsync("doc1", "t", await new TokenEmbeddingGenerator(32).GenerateAsync("t", default), "Public", default); await store.DisposeAsync(); @@ -195,7 +195,7 @@ public async Task DisposeAsync_DropsThePooledConnection_SoALaterStoreOpensTheFil var gen = new TokenEmbeddingGenerator(32); var first = new SqliteVectorStore(path); - await first.IndexAsync("doc1", "hello sqlite", await gen.GenerateAsync("hello sqlite", default), null, default); + await first.IndexAsync("doc1", "hello sqlite", await gen.GenerateAsync("hello sqlite", default), "Public", default); (await first.GetDocumentCountAsync(default)).Should().Be(1, "positive control: the row was written"); await first.DisposeAsync(); @@ -237,7 +237,7 @@ public async Task SearchAsync_NonFiniteMagnitudeQuery_IsRefused_WhileRealQueries await using var store = new SqliteVectorStore(path); var gen = new TokenEmbeddingGenerator(64); foreach (var (id, text) in new[] { ("a", "alpha beta gamma"), ("b", "gamma delta epsilon") }) - await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), null, default); + await store.IndexAsync(id, text, await gen.GenerateAsync(text, default), "Public", default); var refuse = async () => await store.SearchAsync(Filled(64, component), 5, 0.0, null, default); await refuse.Should().ThrowAsync(); @@ -257,9 +257,9 @@ public async Task SearchAsync_NonFiniteRow_IsSkippedWhileTheRestOfTheQueryStillA var path = Path.Combine(Path.GetTempPath(), $"rag_test_{Guid.NewGuid():N}.db"); await using var store = new SqliteVectorStore(path); var gen = new TokenEmbeddingGenerator(64); - await store.IndexAsync("real", "alpha beta gamma", await gen.GenerateAsync("alpha beta gamma", default), null, default); - await store.IndexAsync("nan", "malformed", Filled(64, float.NaN), null, default); - await store.IndexAsync("overflow", "malformed", Filled(64, 1e20f), null, default); + await store.IndexAsync("real", "alpha beta gamma", await gen.GenerateAsync("alpha beta gamma", default), "Public", default); + await store.IndexAsync("nan", "malformed", Filled(64, float.NaN), "Public", default); + await store.IndexAsync("overflow", "malformed", Filled(64, 1e20f), "Public", default); var results = await store.SearchAsync(await gen.GenerateAsync("alpha beta gamma", default), 5, 0.0, null, default); @@ -276,7 +276,7 @@ public async Task SearchAsync_LargeButRepresentableQuery_IsStillAnswered() await using var store = new SqliteVectorStore(path); var unit = new float[64]; unit[0] = 1f; - await store.IndexAsync("real", "real document", unit, null, default); + await store.IndexAsync("real", "real document", unit, "Public", default); var results = await store.SearchAsync(Filled(64, 1e19f), 5, 0.0, null, default); diff --git a/src/Ashlar.Tests.BackgroundAgents/Security/AgentPolicyNarrowingValidatorTests.cs b/src/Ashlar.Tests.BackgroundAgents/Security/AgentPolicyNarrowingValidatorTests.cs new file mode 100644 index 000000000..ededb134a --- /dev/null +++ b/src/Ashlar.Tests.BackgroundAgents/Security/AgentPolicyNarrowingValidatorTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; +using Ashlar.BackgroundAgents.Configuration; +using Ashlar.BackgroundAgents.DataSensitivity; +using Ashlar.BackgroundAgents.Registry; +using Ashlar.BackgroundAgents.Security; +using Xunit; + +namespace Ashlar.Tests.BackgroundAgents.Security; + +/// +/// Isolates the LEVEL compare in (the +/// childLevel.SensitivityValue > parentLevel.SensitivityValue test in +/// EnsureLevelNotBroader). The existing rejection test, +/// SelfExtendInvariantBPolicyNarrowingTests.Rejection_spawn_spec_with_broader_envelope_than_creator_is_refused, +/// broadens the flags AND the levels at once and is refused on BlockExternalLLMs before any level is +/// compared -- so deleting the level compare left the whole suite green. Here parent and child carry +/// IDENTICAL flags, so a level is the only thing that can refuse. +/// +public sealed class AgentPolicyNarrowingValidatorTests +{ + [Fact] + public void A_child_whose_MaxAllowedLevel_exceeds_the_parents_is_refused_on_that_field() + { + var parent = Config("parent", parentId: null, maxData: "Internal", maxAllowed: "Internal"); + var child = Config("child", parentId: "parent", maxData: "Internal", maxAllowed: "Secret"); + + var act = () => Validate(child, parent); + + act.Should().Throw() + .WithMessage("*MaxAllowedLevel 'Secret' exceeds parent 'parent' envelope 'Internal'*"); + } + + [Fact] + public void A_child_whose_MaxDataSensitivity_exceeds_the_parents_is_refused_on_that_field() + { + var parent = Config("parent", parentId: null, maxData: "Internal", maxAllowed: "Internal"); + var child = Config("child", parentId: "parent", maxData: "Confidential", maxAllowed: "Internal"); + + var act = () => Validate(child, parent); + + act.Should().Throw() + .WithMessage("*MaxDataSensitivity 'Confidential' exceeds parent 'parent' envelope 'Internal'*"); + } + + // POSITIVE CONTROL: the same shapes one step down -- equal and narrower levels -- are accepted, + // so the refusals above are the compare and not something else about the fixture (an + // unresolvable level name, a missing parent, a flag) that would refuse any child. + [Theory] + [InlineData("Internal", "Internal")] + [InlineData("Public", "Public")] + [InlineData("Internal", "Public")] + public void An_equal_or_narrower_level_is_accepted(string childMaxData, string childMaxAllowed) + { + var parent = Config("parent", parentId: null, maxData: "Internal", maxAllowed: "Internal"); + var child = Config("child", parentId: "parent", maxData: childMaxData, maxAllowed: childMaxAllowed); + + var act = () => Validate(child, parent); + + act.Should().NotThrow(); + } + + private static void Validate(BackgroundAgentConfig child, BackgroundAgentConfig parent) => + AgentPolicyNarrowingValidator.ValidateOrThrow( + child, + AgentRegistrationOrigin.Machine, + id => id == parent.Id ? new BackgroundAgentInstance { Config = parent } : null, + new DataSensitivityRegistry()); + + // Every flag identical and permissive on both sides: none of the four flag checks can refuse. + private static BackgroundAgentConfig Config(string id, string? parentId, string maxData, string maxAllowed) => new() + { + Id = id, + ParentId = parentId, + Role = "extender", + Commands = ["extend"], + MaxDataSensitivity = maxData, + ExfiltrationPolicy = new ExfiltrationPolicy + { + MaxAllowedLevel = maxAllowed, + BlockExternalLLMs = false, + BlockWebSearch = false, + BlockNetworkExports = false, + RequireLocalOnly = false, + }, + }; +} diff --git a/src/Ashlar.Tests.BackgroundAgents/Security/DataExfiltrationPolicyTests.cs b/src/Ashlar.Tests.BackgroundAgents/Security/DataExfiltrationPolicyTests.cs index b5de4e974..f0214c08e 100644 --- a/src/Ashlar.Tests.BackgroundAgents/Security/DataExfiltrationPolicyTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/Security/DataExfiltrationPolicyTests.cs @@ -25,22 +25,46 @@ private static WorldSnapshot Snapshot(int tick = 0, IReadOnlyDictionary()); } - [Fact] - public void Approve_WhenNoAgentIdInSnapshot_Allows() + // INVERTED. This used to be Approve_WhenNoAgentIdInSnapshot_Allows: a caller escaped every + // exfiltration rule by leaving its id out of the snapshot. An agent the policy cannot identify + // now gets the MOST restrictive policy. + [Theory] + [InlineData("web_search", "web search")] + [InlineData("complete", "external LLM")] + [InlineData("export", "network export")] + public void Approve_WhenNoAgentIdInSnapshot_DeniesEveryExfiltrationTool(string toolId, string reasonFragment) { var registry = new Mock(); var sensitivityRegistry = new DataSensitivityRegistry(); var policy = new DataExfiltrationPolicy(registry.Object, sensitivityRegistry); - var call = ToolCall("web_search"); + var call = ToolCall(toolId); var snapshot = Snapshot(0, new Dictionary()); - policy.Approve(call, snapshot, out var reason).Should().BeTrue(); - reason.Should().Be("OK"); + policy.Approve(call, snapshot, out var reason).Should().BeFalse(); + reason.Should().Contain(reasonFragment).And.Contain("no agent id"); + } + + // A present-but-unusable id is no id: blank, or not a string at all. + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(42)] + public void Approve_WhenAgentIdIsBlankOrNotAString_DeniesAsIfMissing(object agentId) + { + var registry = new Mock(); + var policy = new DataExfiltrationPolicy(registry.Object, new DataSensitivityRegistry()); + + var snapshot = Snapshot(0, new Dictionary { ["agentId"] = agentId }); + + policy.Approve(ToolCall("web_search"), snapshot, out var reason).Should().BeFalse(); + reason.Should().Contain("no agent id"); } + // INVERTED. This used to be Approve_WhenAgentIdNotInRegistry_Allows: a misspelt or unregistered + // id was waved through with reason "OK". [Fact] - public void Approve_WhenAgentIdNotInRegistry_Allows() + public void Approve_WhenAgentIdNotInRegistry_Denies() { var registry = new Mock(); registry.Setup(r => r.GetAgent("agent-1")).Returns((BackgroundAgentInstance?)null); @@ -50,8 +74,59 @@ public void Approve_WhenAgentIdNotInRegistry_Allows() var call = ToolCall("web_search"); var snapshot = Snapshot(0, new Dictionary { ["agentId"] = "agent-1" }); - policy.Approve(call, snapshot, out var reason).Should().BeTrue(); - reason.Should().Be("OK"); + policy.Approve(call, snapshot, out var reason).Should().BeFalse(); + reason.Should().Contain("web search").And.Contain("'agent-1' is not registered"); + } + + // A REGISTERED agent whose config, or whose config's policy, is null has no policy to apply, and + // gets the most restrictive one -- not the permissive `new ExfiltrationPolicy()` defaults (every + // Block flag false), which is what an unguarded fallback would hand it. Base code threw a + // NullReferenceException here; the fallback is new behaviour, so it is pinned per flag. + [Theory] + [InlineData(true, "web_search", "web search")] + [InlineData(false, "web_search", "web search")] + [InlineData(false, "complete", "external LLM")] + [InlineData(false, "export", "network export")] + public void Approve_WhenRegisteredAgentHasNoPolicy_Denies(bool configIsNull, string toolId, string reasonFragment) + { + var instance = new BackgroundAgentInstance + { + Config = configIsNull + ? null! + : new BackgroundAgentConfig { Id = "agent-1", ExfiltrationPolicy = null! }, + }; + var registry = new Mock(); + registry.Setup(r => r.GetAgent("agent-1")).Returns(instance); + var policy = new DataExfiltrationPolicy(registry.Object, new DataSensitivityRegistry()); + + var snapshot = Snapshot(0, new Dictionary { ["agentId"] = "agent-1" }); + + policy.Approve(ToolCall(toolId), snapshot, out var reason).Should().BeFalse(); + reason.Should().Contain(reasonFragment).And.Contain("'agent-1' has no exfiltration policy"); + + // Control: the same agent is still approved a local tool, so the refusal above is the most + // restrictive policy applying and not a policy that has stopped approving. + policy.Approve(ToolCall("repo.fs.read"), snapshot, out var localReason).Should().BeTrue(); + localReason.Should().Be("OK"); + } + + // POSITIVE CONTROL, and the scope of the inversion. This policy only ever refuses the + // exfiltration-class tool ids; an unidentified agent is held to the most restrictive policy a + // REGISTERED agent can have, not to "deny everything". A local tool is still approved -- so the + // denials above are the policy's rules applying, not a policy that has stopped approving. + [Fact] + public void Approve_WhenAgentUnidentified_StillApprovesALocalTool() + { + var registry = new Mock(); + registry.Setup(r => r.GetAgent(It.IsAny())).Returns((BackgroundAgentInstance?)null); + var policy = new DataExfiltrationPolicy(registry.Object, new DataSensitivityRegistry()); + + policy.Approve(ToolCall("repo.fs.read"), Snapshot(0, new Dictionary()), out var noIdReason) + .Should().BeTrue(); + noIdReason.Should().Be("OK"); + policy.Approve(ToolCall("repo.fs.read"), Snapshot(0, new Dictionary { ["agentId"] = "ghost" }), out var unknownReason) + .Should().BeTrue(); + unknownReason.Should().Be("OK"); } [Fact] diff --git a/src/Ashlar.Tests.BackgroundAgents/Trust/CloudSanitizationProxyTests.cs b/src/Ashlar.Tests.BackgroundAgents/Trust/CloudSanitizationProxyTests.cs index c4dd10e58..d1a1e1b51 100644 --- a/src/Ashlar.Tests.BackgroundAgents/Trust/CloudSanitizationProxyTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/Trust/CloudSanitizationProxyTests.cs @@ -64,10 +64,14 @@ public void SanitizeForCloud_WhenNoPii_AllowsWithRedaction() result.SanitizedContext!.UserPrompt.Should().Be("What is the weather?"); } + // INVERTED. This used to be SanitizeForCloud_WhenNoFilter_Allows and pinned the pass-through as + // the contract -- with a prompt that carries an email address. A sanitizer that cannot inspect + // content must block, and say why, rather than wave the prompt through unaudited. [Fact] - public void SanitizeForCloud_WhenNoFilter_Allows() + public void SanitizeForCloud_WhenNoFilter_BlocksAndAuditsTheBlock() { - var proxy = new CloudSanitizationProxy(contentFilter: null); + var auditLog = new InMemorySanitizationAuditLog(); + var proxy = new CloudSanitizationProxy(contentFilter: null, taxonomy: null, auditLog: auditLog); var context = new OutgoingContext { @@ -78,6 +82,45 @@ public void SanitizeForCloud_WhenNoFilter_Allows() var result = proxy.SanitizeForCloud(context); + result.Allowed.Should().BeFalse(); + result.SanitizedContext.Should().BeNull(); + result.BlockReason.Should().Contain("No sensitive-content filter"); + auditLog.GetRecent(10).Should().ContainSingle() + .Which.Disposition.Should().Be("blocked"); + } + + // A prompt with no PII at all is blocked too: without a filter nothing can be shown clean. This + // is what distinguishes "no filter blocks" from "the PII check blocked". + [Fact] + public void SanitizeForCloud_WhenNoFilter_BlocksEvenAPromptWithNoPii() + { + var proxy = new CloudSanitizationProxy(contentFilter: null); + + var result = proxy.SanitizeForCloud(new OutgoingContext + { + SystemPrompt = "System", + UserPrompt = "What is the weather?", + IsAirGapped = false, + }); + + result.Allowed.Should().BeFalse(); + result.BlockReason.Should().Contain("No sensitive-content filter"); + } + + // POSITIVE CONTROL for the two above: an air-gapped context never leaves the machine, so it is + // still allowed with no filter. + [Fact] + public void SanitizeForCloud_WhenNoFilterButAirGapped_StillAllows() + { + var proxy = new CloudSanitizationProxy(contentFilter: null); + + var result = proxy.SanitizeForCloud(new OutgoingContext + { + SystemPrompt = "System", + UserPrompt = "user@test.com", + IsAirGapped = true, + }); + result.Allowed.Should().BeTrue(); } @@ -100,6 +143,9 @@ public void SanitizeForCloud_WhenPiiRedacted_LogsRedaction() result.Allowed.Should().BeTrue(); result.SanitizedContext!.UserPrompt.Should().Contain("[REDACTED]"); var entries = auditLog.GetRecent(10); - entries.Should().NotBeEmpty(); + // Exactly one: the redaction happened once. A second loop used to log every redaction + // again, so the trail showed two for each one that happened. + entries.Should().ContainSingle().Which.Disposition.Should().Be("redacted"); + result.Redactions.Should().ContainSingle(); } } diff --git a/src/Ashlar.Tests.BackgroundAgents/Trust/SanitizingProviderFactoryTests.cs b/src/Ashlar.Tests.BackgroundAgents/Trust/SanitizingProviderFactoryTests.cs index 643741f1e..d728c4606 100644 --- a/src/Ashlar.Tests.BackgroundAgents/Trust/SanitizingProviderFactoryTests.cs +++ b/src/Ashlar.Tests.BackgroundAgents/Trust/SanitizingProviderFactoryTests.cs @@ -19,7 +19,9 @@ public async Task ExecuteLLMAsync_WhenProxyAllows_DelegatesToInner() infraMock.Setup(x => x.ExecuteLLMAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync("response"); - var proxy = new CloudSanitizationProxy(contentFilter: null); + // A real filter: a proxy with NO filter now blocks every cloud prompt, so it cannot stand in + // for "a proxy that allows" any more. "sys"/"user" carry no PII and pass this one. + var proxy = new CloudSanitizationProxy(new SensitiveContentFilter()); var factory = new SanitizingProviderFactory( infraMock.Object, proxy, @@ -196,7 +198,7 @@ public void IsProviderAvailable_DelegatesToInner() var infraMock = new Mock(); infraMock.Setup(x => x.IsProviderAvailable("ollama")).Returns(true); - var proxy = new CloudSanitizationProxy(contentFilter: null); + var proxy = new CloudSanitizationProxy(new SensitiveContentFilter()); var factory = new SanitizingProviderFactory( infraMock.Object, proxy, diff --git a/src/Ashlar.Tests.Infrastructure/Helpers/TempDirTestBase.cs b/src/Ashlar.Tests.Infrastructure/Helpers/TempDirTestBase.cs index bc90111cd..95e487b34 100644 --- a/src/Ashlar.Tests.Infrastructure/Helpers/TempDirTestBase.cs +++ b/src/Ashlar.Tests.Infrastructure/Helpers/TempDirTestBase.cs @@ -1,3 +1,4 @@ +using Ashlar.Abstractions.Paths; using Ashlar.Tests.Application.Helpers; using Xunit; @@ -50,7 +51,7 @@ protected void AssertTempDirIsUnderSystemTemp() { var tempRoot = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var fullTempDir = Path.GetFullPath(_tempDir); - Assert.True(fullTempDir.StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase), + Assert.True(PathContainment.IsWithin(fullTempDir, tempRoot), $"Temp dir should be under {tempRoot}, got {fullTempDir}"); } } diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/DiagnosticSuppressionConventionTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/DiagnosticSuppressionConventionTests.cs new file mode 100644 index 000000000..eb281ce00 --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/DiagnosticSuppressionConventionTests.cs @@ -0,0 +1,1700 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Ashlar.Core.Application.Paths; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// +/// Two ways a warning pragma quietly unmakes a promise, and the rules that close them. +/// +/// What was wrong. (1) Ashlar.Brick.Contracts is a stable-tier package, and the +/// mechanism docs/SdkCompatibilityPolicy.md names for its promise is the public-API analyzer: +/// RS0016 fails the build when a public symbol is in neither PublicAPI.*.txt file. Five files +/// under Authoring/Ports/ opened with a file-wide #pragma warning disable RS0016 and +/// never restored it, so the 18 public types they declare - unchanged since v0.1.0 and published +/// in three releases - were tracked by nothing, and any change to them would have passed every build. +/// (2) src/Ashlar.Hosting/Sdk/Builders/HostAshlarSdkBuilder.cs:7 disabled CS0618 "because +/// AshlarSdkBuilder is an obsolete type forwarder in this file"; #223 (2026-07-04) moved the forwarder +/// out, and the pragma then suppressed nothing for 88 days while still reading as a reason. A dead +/// suppression is a loaded one: the next obsolete call written into that file is silenced on arrival. +/// +/// Rule 1 - public-API tracking is never switched off. No source anywhere in the tree +/// disables RS0016, RS0017, RS0026 or RS0027 by pragma or by [SuppressMessage]/ +/// [UnconditionalSuppressMessage], and no source carries a bare #pragma warning disable +/// (it switches off every diagnostic, these included). No MSBuild file lists those IDs in +/// NoWarn or WarningsNotAsErrors; no .editorconfig/.globalconfig lowers +/// their severity to none, silent or suggestion, or tells the analyzer to skip a namespace +/// (dotnet_public_api_analyzer.skip_namespaces, the same hole as the Ports pragmas by another +/// spelling); no config that applies to a project using the analyzer lowers the ApiDesign +/// category or every analyzer that way; and neither such a project nor a Directory.Build.* +/// above it turns analyzers off (RunAnalyzers, RunAnalyzersDuringBuild) or removes an +/// API file from AdditionalFiles. Every project that references the analyzer wires BOTH API +/// files as AdditionalFiles, since without them the analyzer has nothing to compare. The source scan is +/// deliberately wider than the analyzer's projects: a pragma acts on the file it is in, and a file is +/// compiled into those projects from outside their directories too (the Ashlar.Compat polyfills +/// linked by Directory.Build.targets), while an RS0016 pragma anywhere else is dead by definition. +/// +/// Rule 2 - a CS0618 or ASHLAREXP001 disable names what it is for, and that thing is still +/// there. A #pragma warning disable of either id - in any spelling the compiler accepts: +/// any case, and for CS0618 the bare number too (618, 0618), which Roslyn maps to +/// CS0618 exactly as it does on /nowarn - must carry a trailing comment that names, as +/// an exact identifier, a symbol this tree declares [Obsolete] (without error: true, +/// which is CS0619, and without a DiagnosticId, which replaces CS0618) or [Experimental] +/// with the ASHLAREXP001 id; at least one symbol it names must occur in the code the pragma governs +/// (from the pragma to a matching restore, else to the end of the file) OUTSIDE a declaration that +/// carries the same attribute - the compiler reports neither diagnostic inside an obsolete or +/// experimental context, which is exactly why the two Ashlar.Sdk/Legacy pragmas below are +/// dead; and the comment must carry a reason of at least words +/// besides the names. A disable that cannot carry such a comment is not allowed to stand in for one: +/// a [SuppressMessage]/[UnconditionalSuppressMessage] naming either id, a +/// NoWarn or WarningsNotAsErrors (element or attribute) listing either id in any MSBuild +/// file, and a dotnet_diagnostic.<id>.severity of none, silent or suggestion in any +/// .editorconfig/.globalconfig are all Rule 2 violations. A project-wide disable names +/// no symbol by construction, so each one that exists must be on +/// , which records why; that list fails in both directions +/// too. +/// +/// Grandfathered, and why it is a ratchet rather than an exemption. Eight existing disables +/// fail Rule 2 and live in files this change does not own; lists each by +/// its exact text. The list fails in BOTH directions: a new violation fails, and an entry whose pragma +/// has been fixed, edited or deleted fails too, so the list can only shrink and every shrink is a +/// reviewed diff. What each needs is in its note. Measured with the compiler on f0fd3fde (removing the +/// pragma and building): both Ashlar.Sdk/Legacy CS0618 pragmas are dead (the build stays clean), +/// the test one is live, and all six ASHLAREXP001 pragmas are live - five of them simply name no symbol +/// (touch-set, lineage), and the sixth, in ObjectiveDocument.cs, already passes. +/// +/// What this does NOT prove. Rule 2 is lexical. It proves a disable names a real +/// deprecated or experimental symbol that the governed code still mentions outside that symbol's own +/// context; it cannot prove the compiler would report there (a use inside a lambda in an obsolete +/// member, nameof, or a same-named symbol from another assembly all read as uses). A CS0618 +/// disable for an obsolete API declared OUTSIDE this tree (BCL, a package) cannot name an in-tree +/// symbol and so fails; that is a deliberate, reviewed exception to add to , +/// not something to loosen. CS0612 (an [Obsolete] with no message) is a different id and is +/// not policed. Neither rule evaluates MSBuild: conditions are ignored (a disable is flagged whatever +/// its Condition), and a disable routed through a property of another name +/// (<NoWarn>$(MyList)</NoWarn>) or passed on a command line (-p:NoWarn=, +/// -nowarn: in a script or workflow) is not seen. Nor does either rule check +/// TreatWarningsAsErrors (conditional in the root props) or WarningLevel, or read the +/// bulk dotnet_analyzer_diagnostic.* settings for Rule 2. Rule 1 follows no imports beyond the +/// Directory.Build.* chain, and an // <auto-generated/> header is out of scope. +/// Code inside #if branches is scanned whether or not any build defines the symbol - +/// over-approximating costs a red test, never a silent pass. +/// +[Trait("Category", "Certification")] +public sealed class DiagnosticSuppressionConventionTests +{ + private const string ObsoleteId = "CS0618"; + private const string ExperimentalId = "ASHLAREXP001"; + private const string ApiAnalyzerPackage = "Microsoft.CodeAnalysis.PublicApiAnalyzers"; + private const string BrickContractsProject = "src/Ashlar.Brick.Contracts/Ashlar.Brick.Contracts.csproj"; + + /// A comment must say why, not only what: this many words besides the names. + private const int MinimumReasonWords = 3; + + // Non-vacuity floors. STATED here and lowered only by a visible diff: a count derived from the tree + // moves down with the tree and can never fire. Measured on f0fd3fde (2026-09-30): the walk reads + // 3149 C# files and 133 MSBuild files; 6 projects reference the analyzer; the tree declares 65 + // distinct ASHLAREXP001 [Experimental] names and 2 CS0618 [Obsolete] names (AshlarSdkBuilder, + // AddAshlarSdk); after this change it carries 15 warning-disable pragmas. The floors sit under those + // numbers, and above what a broken walk or parser yields (zero, or the handful in one directory). + // The obsolete floor is 1, not the measured 2: a broken parser yields 0, while removing one of the + // two forwarders is the normal end of a deprecation (docs/SdkCompatibilityPolicy.md) and must not + // read as a broken parser. Removing the LAST [Obsolete] symbol in the tree legitimately yields 0; + // lower this floor to 0 in that diff (the in-memory controls below still pin the parser). + private const int SourceFilesFloor = 2500; + private const int MsBuildFilesFloor = 100; + private const int ApiProjectsFloor = 6; + private const int ExperimentalSymbolsFloor = 40; + private const int ObsoleteSymbolsFloor = 1; + private const int DisablePragmasFloor = 10; + + private static readonly Regex ApiTrackingId = new(@"(?Obsolete|Experimental)(?:Attribute)?(?![\w.])", + RegexOptions.CultureInvariant); + + private static readonly Regex Identifier = new(@"@?[A-Za-z_][A-Za-z0-9_]*", RegexOptions.CultureInvariant); + + private static readonly Regex ReasonWord = new(@"[A-Za-z]{2,}", RegexOptions.CultureInvariant); + + private static readonly Regex WeakSeverity = new(@"^(none|silent|suggestion)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly Regex DiagnosticSeverity = new( + @"^dotnet_diagnostic\.(?[A-Za-z]+[0-9]+)\.severity\s*=\s*(?[A-Za-z]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly Regex AnalyzerSeverity = new( + @"^dotnet_analyzer_diagnostic\.(?:category-(?[A-Za-z-]+)\.)?severity\s*=\s*(?[A-Za-z]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly Regex SkipNamespaces = new(@"^dotnet_public_api_analyzer\.skip_namespaces\s*=\s*\S", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly HashSet NotANameKeywords = new(StringComparer.Ordinal) + { + "public", "private", "protected", "internal", "static", "sealed", "abstract", "virtual", "override", + "readonly", "const", "new", "partial", "extern", "unsafe", "volatile", "async", "required", "file", + "event", "fixed", "implicit", "explicit", "ref", "out", "in", "params", "void", "get", "set", "init", + "add", "remove", "where", "operator", "this", "return", + }; + + /// + /// Existing Rule 2 violations in files this change does not own, keyed by exact pragma text. Fails in + /// both directions (see the class summary). Each note says what clears the entry. + /// + private static readonly (string Path, string Pragma, string Clears)[] Grandfathered = + [ + ("src/Ashlar.Sdk/Legacy/AshlarSdkBuilder.cs", + "#pragma warning disable CS0618 // intentional obsolete forwarding surface for NuGet compatibility", + "DELETE the line: it is dead (measured: Ashlar.Sdk builds clean without it; the only use of " + + "AshlarSdkBuilder here is inside its own [Obsolete] declaration)."), + ("src/Ashlar.Sdk/Legacy/AshlarSdkLegacyExtensions.cs", + "#pragma warning disable CS0618 // intentional obsolete forwarding surface for NuGet compatibility", + "DELETE the line: it is dead (measured: Ashlar.Sdk builds clean without it; every use of " + + "AshlarSdkBuilder here is inside the [Obsolete] AddAshlarSdk)."), + ("src/Ashlar.Tests.Infrastructure/Tests/SDK/HostAshlarSdkBuilderGapCoverageTests.cs", + "#pragma warning disable CS0618", + "live, but says nothing: '// AshlarSdkBuilder: this fact exercises the obsolete forwarding " + + "constructor on purpose'."), + ("src/Ashlar.Infrastructure/Certification/AnalyzerFenceGate.cs", + "#pragma warning disable ASHLAREXP001 // The gate enforces the experimental autonomy contract (touch-set, kernel prefixes) by design; see docs/SdkCompatibilityPolicy.md.", + "live (TouchSet, TrustKernel); name them: '// TouchSet, TrustKernel: the gate enforces ...'."), + ("src/Ashlar.Infrastructure/Certification/AnalyzerFencePostValidator.cs", + "#pragma warning disable ASHLAREXP001 // The gate enforces the experimental autonomy contract (touch-set) by design; see docs/SdkCompatibilityPolicy.md.", + "live (TouchSet); name it: '// TouchSet: the gate enforces ...'."), + ("src/Ashlar.Infrastructure/Certification/CertificationGate.cs", + "#pragma warning disable ASHLAREXP001 // The gate enforces the experimental autonomy contract (touch-set, lineage) by design; see docs/SdkCompatibilityPolicy.md.", + "live (GenerationLineage, RecursionDiscipline, CertificationRequest.Lineage and .TouchSet); " + + "name them: '// GenerationLineage, RecursionDiscipline, Lineage, TouchSet: the gate enforces ...'."), + ("src/Ashlar.Infrastructure/Certification/GenerationLineageInputs.cs", + "#pragma warning disable ASHLAREXP001 // Projects the experimental lineage contract into certificate evidence by design; see docs/SdkCompatibilityPolicy.md.", + "live (GenerationLineage); name it: '// GenerationLineage: projects the experimental ...'."), + ("src/Ashlar.BackgroundAgents/Objectives/ObjectiveDocumentParser.cs", + "#pragma warning disable ASHLAREXP001 // Parses the experimental tiering fields (source, touch-set) of an objective by design; see docs/SdkCompatibilityPolicy.md.", + "live (ObjectiveSource, TouchSet, ObjectiveDocument.Source and .Touch); name them: " + + "'// ObjectiveSource, TouchSet, Source, Touch: parses the experimental tiering fields ...'."), + ]; + + /// + /// Every project-wide disable of CS0618 or ASHLAREXP001 in the tree, keyed by file and exact setting + /// text. A project-wide disable covers every file its project compiles, so it cannot name a symbol; + /// the reason lives here instead, and a new one needs a reviewed entry. Fails in both directions, + /// like : an unlisted disable fails, and so does an entry whose setting + /// has been edited or deleted. Both below were measured on f0fd3fde and each carries its own comment + /// in the file saying the same thing. + /// + private static readonly (string Path, string Setting, string Why)[] ReviewedProjectWideDisables = + [ + ("Directory.Build.targets", + "$(NoWarn);ASHLAREXP001", + "Conditioned on IsTestProject: the repo's own test projects exercise the experimental autonomy " + + "surface by design, and the diagnostic exists for EXTERNAL consumers " + + "(docs/SdkCompatibilityPolicy.md, 'Experimental tier')."), + ("spikes/autonomy-first-flight/FirstFlight/FirstFlight.csproj", + "$(NoWarn);ASHLAREXP001", + "The spike IS a consumer of the experimental autonomy surface; it is outside Ashlar.sln and " + + "every CI gate, and its output is a recorded run, not a shipped artifact."), + ]; + + private static readonly Lazy Tree = new(() => RepoTree.Load(RepoPathResolver.FindRepoRoot())); + + private readonly ITestOutputHelper _output; + + public DiagnosticSuppressionConventionTests(ITestOutputHelper output) => _output = output; + + // ------------------------------------------------------------------------------------------------ + // The real tree + // ------------------------------------------------------------------------------------------------ + + [Fact] + public void Nothing_in_the_tree_switches_off_public_api_tracking() + { + var tree = Tree.Value; + tree.Sources.Count.Should().BeGreaterThanOrEqualTo(SourceFilesFloor, + "the walk read {0} C# files; below the stated floor of {1} it is broken and an empty scan would pass", + tree.Sources.Count, SourceFilesFloor); + tree.MsBuild.Count.Should().BeGreaterThanOrEqualTo(MsBuildFilesFloor, + "the walk read {0} MSBuild files; below the stated floor of {1} it is broken", tree.MsBuild.Count, MsBuildFilesFloor); + tree.AnalyzerConfigs.Should().Contain(c => c.Path == ".editorconfig", + "the root .editorconfig applies to every project; a walk that misses it reads no analyzer config at all"); + + var problems = new List(); + foreach (var source in tree.Sources) + problems.AddRange(ApiTrackingSourceViolations(source)); + foreach (var (path, doc) in tree.MsBuild) + problems.AddRange(ApiTrackingMsBuildViolations(path, doc, tree.GovernsApiProject(path))); + foreach (var (path, text) in tree.AnalyzerConfigs) + problems.AddRange(ApiTrackingAnalyzerConfigViolations(path, text, tree.ConfigAppliesToApiProject(path))); + + problems.Should().BeEmpty( + "public-API tracking is what makes a stable package's promise enforceable; switching it off " + + "for a file or a project leaves that surface promised by nothing. Record the surface in " + + "PublicAPI.Shipped.txt or PublicAPI.Unshipped.txt instead (docs/SdkCompatibilityPolicy.md). " + + "{0} found:\n{1}", problems.Count, string.Join("\n", problems)); + + _output.WriteLine($"sources={tree.Sources.Count} msbuild={tree.MsBuild.Count} " + + $"analyzer-configs={tree.AnalyzerConfigs.Count} api-projects={tree.ApiProjects.Count}"); + } + + [Fact] + public void Every_public_api_analyzer_project_wires_both_api_files() + { + var tree = Tree.Value; + tree.ApiProjects.Count.Should().BeGreaterThanOrEqualTo(ApiProjectsFloor, + "{0} projects reference {1}; below the stated floor of {2} discovery is broken and every check " + + "scoped to them passes on nothing", tree.ApiProjects.Count, ApiAnalyzerPackage, ApiProjectsFloor); + tree.ApiProjects.Should().Contain(BrickContractsProject); + + var shared = tree.MsBuild + .Where(m => !m.Path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + .Where(m => m.Doc.Descendants().Any(e => + (e.Name.LocalName is "PackageReference" or "GlobalPackageReference") + && string.Equals((string?)e.Attribute("Include"), ApiAnalyzerPackage, StringComparison.OrdinalIgnoreCase))) + .Select(m => m.Path) + .ToList(); + shared.Should().BeEmpty( + "discovery reads the analyzer reference from project files; a shared MSBuild file that adds it " + + "makes every project below it an analyzer project that this convention cannot see. Extend " + + "discovery before adding one: {0}", string.Join(", ", shared)); + + var problems = new List(); + foreach (var project in tree.ApiProjects) + { + var doc = tree.MsBuild.Single(m => m.Path == project).Doc; + var included = doc.Descendants() + .Where(e => e.Name.LocalName == "AdditionalFiles") + .Select(e => ((string?)e.Attribute("Include") ?? "").Replace('\\', '/').Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var dir = DirectoryOf(project); + foreach (var file in new[] { "PublicAPI.Shipped.txt", "PublicAPI.Unshipped.txt" }) + { + if (!included.Contains(file)) + problems.Add($"{project}: no , so the analyzer never reads it"); + if (!File.Exists(Path.Combine(tree.Root, dir, file))) + problems.Add($"{project}: {dir}/{file} does not exist"); + } + } + + problems.Should().BeEmpty("the analyzer compares the surface against these two files and nothing else:\n{0}", + string.Join("\n", problems)); + } + + [Fact] + public void Every_obsolete_or_experimental_disable_names_a_live_symbol_and_a_reason() + { + var tree = Tree.Value; + var symbols = tree.Symbols; + symbols.Experimental.Count.Should().BeGreaterThanOrEqualTo(ExperimentalSymbolsFloor, + "found {0} [Experimental({1})] names; below the stated floor of {2} the declaration parser is broken " + + "and every pragma would read as naming nothing", symbols.Experimental.Count, ExperimentalId, ExperimentalSymbolsFloor); + symbols.Obsolete.Count.Should().BeGreaterThanOrEqualTo(ObsoleteSymbolsFloor, + "found {0} CS0618 [Obsolete] names against a stated floor of {1}. If the tree still declares an " + + "[Obsolete] symbol (without error: true or a DiagnosticId), the declaration parser is broken. If " + + "the last one was removed at the end of its deprecation, lower ObsoleteSymbolsFloor to 0 in " + + "that diff", symbols.Obsolete.Count, ObsoleteSymbolsFloor); + symbols.Obsolete.Should().Contain("AshlarSdkBuilder", + "src/Ashlar.Hosting/Sdk/Builders/AshlarSdkBuilder.cs declared it [Obsolete] when this anchor was " + + "written. If it is still declared there, the declaration parser is broken. If it was removed - " + + "the normal end of a deprecation under docs/SdkCompatibilityPolicy.md - point this anchor at " + + "another in-tree [Obsolete] symbol, or delete it together with the floor above"); + symbols.Obsolete.Should().NotContain("Mutate", + "LiteDbAtomic.Mutate is [Obsolete(..., error: true)]: a use is CS0619, which a CS0618 disable cannot silence"); + symbols.Experimental.Should().Contain(new[] { "TouchSet", "GenerationLineage", "Source", "Touch" }, + "these were declared [Experimental(ASHLAREXP001)] when this anchor was written. If they still are, " + + "the declaration parser is broken. If one graduated or was removed, point the anchor (and the " + + "ObjectiveDocument.cs anchor below) at another experimental symbol in that diff"); + + var pragmas = tree.Sources.SelectMany(Pragmas).Where(p => p.Disable).ToList(); + pragmas.Count.Should().BeGreaterThanOrEqualTo(DisablePragmasFloor, + "found {0} warning-disable pragmas; below the stated floor of {1} the pragma scan is broken", + pragmas.Count, DisablePragmasFloor); + + var found = tree.Sources.SelectMany(s => DeprecationSuppressionViolations(s, symbols)) + .Concat(tree.Sources.SelectMany(DeprecationSuppressAttributeViolations)) + .ToList(); + + // Positive anchor: the one real disable that already complies must be seen AND pass, so a scan + // that stopped finding uses (every pragma "dead") or stopped finding pragmas cannot pass here. + const string compliant = "src/Ashlar.BackgroundAgents/Objectives/ObjectiveDocument.cs"; + pragmas.Should().Contain(p => p.Path == compliant && p.Ids.Contains(ExperimentalId), + "{0} carries a live, named ASHLAREXP001 disable; not finding it means the scan is broken", compliant); + found.Should().NotContain(v => v.Path == compliant, + "{0}'s disable names Source and Touch, both used outside their [Experimental] declarations", compliant); + + var allowed = Grandfathered.Select(g => (g.Path, g.Pragma)).ToHashSet(); + var unexpected = found.Where(v => !allowed.Contains((v.Path, v.Pragma))).Select(v => v.Message).ToList(); + var seen = found.Select(v => (v.Path, v.Pragma)).ToHashSet(); + var stale = Grandfathered.Where(g => !seen.Contains((g.Path, g.Pragma))) + .Select(g => $"{g.Path}: '{g.Pragma}' is grandfathered but no longer violates; delete its entry") + .ToList(); + + unexpected.Should().BeEmpty( + "a CS0618/ASHLAREXP001 disable must name, as an exact identifier, an in-tree [Obsolete] or " + + "[Experimental({0})] symbol that the code it governs still uses outside that symbol's own " + + "context, and say why in at least {1} words. A dead disable silences the next use written " + + "into its file. {2} found:\n{3}", ExperimentalId, MinimumReasonWords, unexpected.Count, string.Join("\n", unexpected)); + stale.Should().BeEmpty("the grandfather list only shrinks, and each shrink is a reviewed diff:\n{0}", + string.Join("\n", stale)); + + _output.WriteLine($"experimental-names={symbols.Experimental.Count} obsolete-names={symbols.Obsolete.Count} " + + $"disable-pragmas={pragmas.Count} violations={found.Count} grandfathered={Grandfathered.Length}"); + foreach (var p in pragmas) + _output.WriteLine($" {p.Path}:{p.Line} disable {string.Join(",", p.Ids)}"); + foreach (var g in Grandfathered) + _output.WriteLine($" grandfathered {g.Path}: {g.Clears}"); + } + + [Fact] + public void Every_project_wide_obsolete_or_experimental_disable_is_reviewed() + { + var tree = Tree.Value; + tree.MsBuild.Count.Should().BeGreaterThanOrEqualTo(MsBuildFilesFloor, + "the walk read {0} MSBuild files; below the stated floor of {1} it is broken", tree.MsBuild.Count, MsBuildFilesFloor); + tree.AnalyzerConfigs.Should().Contain(c => c.Path == ".editorconfig", + "the root .editorconfig applies to every project; a walk that misses it reads no analyzer config at all"); + + var found = tree.MsBuild.SelectMany(m => DeprecationMsBuildDisables(m.Path, m.Doc)) + .Concat(tree.AnalyzerConfigs.SelectMany(c => DeprecationAnalyzerConfigDisables(c.Path, c.Text))) + .ToList(); + + // Non-vacuity is the stale direction: the two reviewed entries exist today, so a scan that stopped + // finding anything turns both of them stale and fails below. + var reviewed = ReviewedProjectWideDisables.Select(r => (r.Path, r.Setting)).ToHashSet(); + var unexpected = found.Where(v => !reviewed.Contains((v.Path, v.Pragma))).Select(v => v.Message).ToList(); + var seen = found.Select(v => (v.Path, v.Pragma)).ToHashSet(); + var stale = ReviewedProjectWideDisables.Where(r => !seen.Contains((r.Path, r.Setting))) + .Select(r => $"{r.Path}: '{r.Setting}' is on the reviewed list but no longer found; delete its entry") + .ToList(); + + unexpected.Should().BeEmpty( + "a project-wide disable of {0} or {1} silences every use in every file the project compiles, so it " + + "cannot name the symbol it is for or say why. Use a scoped '#pragma warning disable' that names " + + "the symbol and gives a reason (Rule 2), or, if the whole project genuinely consumes the surface, " + + "add a ReviewedProjectWideDisables entry that says why. {2} found:\n{3}", + ObsoleteId, ExperimentalId, unexpected.Count, string.Join("\n", unexpected)); + stale.Should().BeEmpty("the reviewed list must match the tree exactly:\n{0}", string.Join("\n", stale)); + + _output.WriteLine($"project-wide-disables={found.Count} reviewed={ReviewedProjectWideDisables.Length}"); + foreach (var v in found) + _output.WriteLine($" {v.Message}"); + } + + // ------------------------------------------------------------------------------------------------ + // Controls: the matchers over in-memory text. Negative controls must be caught; positive ones not. + // ------------------------------------------------------------------------------------------------ + + [Theory] + [InlineData("#pragma warning disable RS0016\npublic class A { }")] + [InlineData(" # pragma warning disable CS1591, rs0017 // mixed case, second id\npublic class A { }")] + [InlineData("#pragma warning disable RS0026\n")] + [InlineData("#pragma warning disable RS0027 // restored later\n#pragma warning restore RS0027\n")] + [InlineData("#pragma warning disable\npublic class A { }")] + [InlineData("[SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\")]\npublic class A { }")] + [InlineData("[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(\"ApiDesign\", \"RS0017\", Justification = \"x\")]")] + [InlineData("[UnconditionalSuppressMessage(\"ApiDesign\",\n \"RS0026:Do not add multiple overloads with optional parameters\")]\npublic class A { }")] + [InlineData("[SuppressMessageAttribute(\"ApiDesign\", \"RS0027\")] public void M() { }")] + public void A_source_suppression_of_api_tracking_is_caught(string source) + { + var file = SourceFile.Parse("fixture/A.cs", source); + ApiTrackingSourceViolations(file).Should().NotBeEmpty("this spelling switches off public-API tracking:\n{0}", source); + } + + [Theory] + [InlineData("#pragma warning restore RS0016\n")] + [InlineData("#pragma warning disable RS1001 // Missing [DiagnosticAnalyzer]: intentional\n#pragma warning restore RS1001\n")] + [InlineData("#pragma warning disable RS00160 // not an id this rule owns\n")] + [InlineData("// #pragma warning disable RS0016 (commented out)\npublic class A { }")] + [InlineData("/*\n#pragma warning disable RS0016\n*/\npublic class A { }")] + [InlineData("var s = \"#pragma warning disable RS0016\";")] + [InlineData("var s = @\"\n#pragma warning disable RS0016\n\";")] + [InlineData("var s = \"\"\"\n#pragma warning disable RS0016\n[SuppressMessage(\"ApiDesign\", \"RS0016\")]\n\"\"\";")] + [InlineData("[SuppressMessage(\"Design\", \"CA1062:Validate arguments\")] public void M(object o) { }")] + [InlineData("var text = \"SuppressMessage(\\\"ApiDesign\\\", \\\"RS0016\\\")\";")] + public void A_source_that_does_not_suppress_api_tracking_passes(string source) + { + var file = SourceFile.Parse("fixture/A.cs", source); + ApiTrackingSourceViolations(file).Should().BeEmpty("this text does not suppress public-API tracking:\n{0}", source); + } + + [Theory] + [InlineData("$(NoWarn);1591;RS0016", false)] + [InlineData("$(NoWarn);RS0017;", false)] + [InlineData("rs0026", false)] + [InlineData("", false)] + [InlineData("false", true)] + [InlineData(" False ", true)] + [InlineData("", true)] + public void An_msbuild_suppression_of_api_tracking_is_caught(string xml, bool governsApiProject) + { + ApiTrackingMsBuildViolations("fixture/A.csproj", XDocument.Parse(xml), governsApiProject) + .Should().NotBeEmpty("this switches off public-API tracking:\n{0}", xml); + } + + [Theory] + [InlineData("$(NoWarn);1591;RS0036;RS0037", true)] + [InlineData("false", false)] + [InlineData("trueRS0016", true)] + [InlineData("", true)] + [InlineData("", false)] + public void An_msbuild_file_that_does_not_suppress_api_tracking_passes(string xml, bool governsApiProject) + { + ApiTrackingMsBuildViolations("fixture/A.csproj", XDocument.Parse(xml), governsApiProject) + .Should().BeEmpty("this does not switch off public-API tracking for an analyzer project:\n{0}", xml); + } + + [Theory] + [InlineData("[*.cs]\ndotnet_diagnostic.RS0016.severity = none", false, true)] + [InlineData("[*.cs]\ndotnet_diagnostic.rs0017.severity=silent", false, true)] + [InlineData("is_global = true\ndotnet_diagnostic.RS0027.severity = suggestion", false, true)] + [InlineData("[*.cs]\ndotnet_analyzer_diagnostic.category-ApiDesign.severity = none", true, true)] + [InlineData("[*.cs]\ndotnet_analyzer_diagnostic.severity = silent", true, true)] + [InlineData("[*.cs]\ndotnet_diagnostic.RS0016.severity = error", true, false)] + [InlineData("[*.cs]\n# dotnet_diagnostic.RS0016.severity = none", true, false)] + [InlineData("[*.cs]\ndotnet_diagnostic.RS0036.severity = none", true, false)] + [InlineData("[*.cs]\ndotnet_analyzer_diagnostic.category-ApiDesign.severity = none", false, false)] + [InlineData("[*.cs]\ndotnet_analyzer_diagnostic.category-Style.severity = none", true, false)] + [InlineData("[*.cs]\ndotnet_public_api_analyzer.skip_namespaces = Ashlar.Core.Domain.Bricks.Ports", false, true)] + [InlineData("[*.cs]\ndotnet_public_api_analyzer.require_api_files = true", true, false)] + public void An_analyzer_config_relaxing_api_tracking_is_caught_and_only_then(string text, bool appliesToApiProject, bool caught) + { + var problems = ApiTrackingAnalyzerConfigViolations("fixture/.editorconfig", text, appliesToApiProject).ToList(); + (problems.Count > 0).Should().Be(caught, "config:\n{0}\nproblems: {1}", text, string.Join("; ", problems)); + } + + /// + /// The defect this convention was written for, verbatim: HostAshlarSdkBuilder.cs as it stood on + /// f0fd3fde, 88 days after #223 moved the forwarder its CS0618 disable was written for. + /// + private const string DeadPragmaAsShipped = """ + using Ashlar.Abstractions; + using Ashlar.Core.Domain.Agents; + using Ashlar.Core.Domain.Bricks; + using Ashlar.Infrastructure.Sdk.Ports; + + namespace Ashlar.Hosting.Sdk.Builders; + #pragma warning disable CS0618 // AshlarSdkBuilder is an obsolete type forwarder in this file + + /// + /// Default implementation of . Configures for kernel registration. + /// + public class HostAshlarSdkBuilder : IAshlarSdkBuilder + { + private readonly AshlarSdkOptions _options; + + /// + /// Creates a new SDK builder with the given options. + /// + /// Options to populate. + public HostAshlarSdkBuilder(AshlarSdkOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public IAshlarSdkBuilder RegisterBrick() where T : DomainBrick + { + _options.BrickTypes.Add(typeof(T)); + return this; + } + } + """; + + /// The forwarder's own file on f0fd3fde: the declaration the dead pragma pointed at. + private const string ForwarderAsShipped = """ + namespace Ashlar.Hosting.Sdk.Builders; + + /// + /// Back-compat type name for . + /// + [Obsolete("Renamed to HostAshlarSdkBuilder.", error: false)] + public sealed class AshlarSdkBuilder : HostAshlarSdkBuilder + { + /// + public AshlarSdkBuilder(AshlarSdkOptions options) + : base(options) + { + } + } + """; + + private const string ExperimentalDeclarations = """ + namespace Ashlar.Core.Application.Autonomy; + + [Experimental(AutonomyExperimental.DiagnosticId, UrlFormat = AutonomyExperimental.UrlFormat)] + public sealed record TouchSet(IReadOnlyList Paths); + + public sealed class LegacyStore + { + [Obsolete("Use the async overload.", error: true)] + public void Mutate(Action a) { } + + [Obsolete("Has its own id.", DiagnosticId = "ASHOBS1")] + public void Reset() { } + } + """; + + /// + /// The shipped pragma as written, and in the bare-number spellings the compiler reads as the same id + /// (Roslyn turns a numeric pragma id into "CS" + the number padded to four digits). + /// + [Theory] + [InlineData("CS0618")] + [InlineData("618")] + [InlineData("0618")] + [InlineData("cs0618")] + public void The_disable_that_outlived_its_forwarder_is_caught_as_dead(string spelling) + { + var source = DeadPragmaAsShipped.Replace("#pragma warning disable CS0618 ", $"#pragma warning disable {spelling} ", + StringComparison.Ordinal); + source.Should().Contain($"#pragma warning disable {spelling} // AshlarSdkBuilder", "the fixture must carry the spelling under test"); + + var violations = Rule2(source, ForwarderAsShipped); + + violations.Should().ContainSingle("the shipped HostAshlarSdkBuilder.cs pragma governs no use of AshlarSdkBuilder") + .Which.Message.Should().Contain("dead").And.Contain("AshlarSdkBuilder"); + } + + [Theory] + // A live, named, reasoned disable passes. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder: this fact pins the forwarding constructor on purpose\nvar b = new AshlarSdkBuilder(options);\n#pragma warning restore CS0618\n", null)] + // Fully qualified use counts as a use. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder kept for one more minor release\nvar b = new Ashlar.Hosting.Sdk.Builders.AshlarSdkBuilder(o);\n", null)] + // No comment at all. + [InlineData("#pragma warning disable CS0618\nvar b = new AshlarSdkBuilder(options);\n", "no comment")] + // Names a symbol that is not [Obsolete]. + [InlineData("#pragma warning disable CS0618 // HostAshlarSdkBuilder is the forwarding target here\nvar b = new AshlarSdkBuilder(options);\n", "names no")] + // Names the symbol but gives no reason. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder\nvar b = new AshlarSdkBuilder(options);\n", "reason")] + // Restored before the only use: dead. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder: pins the forwarding constructor\n#pragma warning restore CS0618\nvar b = new AshlarSdkBuilder(options);\n", "dead")] + // The only mention is inside a string literal: dead. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder: pins the forwarding constructor\nvar s = \"AshlarSdkBuilder\";\n", "dead")] + // The only mention is a substring of another identifier: dead. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder: pins the forwarding constructor\nvar b = new HostAshlarSdkBuilder(options);\n", "dead")] + // The only use is inside an [Obsolete] member, where the compiler never reports CS0618: dead. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder: forwarding surface for NuGet compatibility\npublic static class Legacy\n{\n [Obsolete(\"Use the new one.\", error: false)]\n public static void AddAshlarSdk(Action? configure = null)\n {\n configure?.Invoke(new AshlarSdkBuilder(null!));\n }\n}\n", "dead")] + // ...but the same use outside that member is live. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder: forwarding surface for NuGet compatibility\npublic static class Legacy\n{\n [Obsolete(\"Use the new one.\", error: false)]\n public static void AddAshlarSdk() { }\n\n public static object Make() => new AshlarSdkBuilder(null!);\n}\n", null)] + // CS0619 (error: true) and a custom DiagnosticId are not CS0618: naming them names nothing. + [InlineData("#pragma warning disable CS0618 // Mutate is still called by the migration shim\nstore.Mutate(() => { });\n", "names no")] + [InlineData("#pragma warning disable CS0618 // Reset is still called by the migration shim\nstore.Reset();\n", "names no")] + // ASHLAREXP001: exact identifier required; prose spelling does not name the symbol. + [InlineData("#pragma warning disable ASHLAREXP001 // TouchSet: the gate enforces the experimental contract by design\nvar t = new TouchSet(paths);\n", null)] + [InlineData("#pragma warning disable ASHLAREXP001 // The gate enforces the experimental contract (touch-set) by design\nvar t = new TouchSet(paths);\n", "names no")] + // A disable naming both ids is checked for each. + [InlineData("#pragma warning disable CS0618, ASHLAREXP001 // TouchSet: the gate enforces the experimental contract by design\nvar t = new TouchSet(paths);\n", "names no")] + // The bare number is CS0618 to the compiler, so it is judged exactly like CS0618. + [InlineData("#pragma warning disable 618\nvar b = new AshlarSdkBuilder(options);\n", "no comment")] + [InlineData("#pragma warning disable 0618 // AshlarSdkBuilder: pins the forwarding constructor\nvar s = \"x\";\n", "dead")] + [InlineData("#pragma warning disable 618 // HostAshlarSdkBuilder is the forwarding target here\nvar b = new AshlarSdkBuilder(options);\n", "names no")] + [InlineData("#pragma warning disable 618 // AshlarSdkBuilder: this fact pins the forwarding constructor on purpose\nvar b = new AshlarSdkBuilder(options);\n#pragma warning restore 618\n", null)] + // A numeric restore ends a CS0618 region, so the use after it is not governed: dead. + [InlineData("#pragma warning disable CS0618 // AshlarSdkBuilder: pins the forwarding constructor\n#pragma warning restore 0618\nvar b = new AshlarSdkBuilder(options);\n", "dead")] + // Other numbers are other ids: CS8618 and CS6180 are not CS0618, so Rule 2 has nothing to say. + [InlineData("#pragma warning disable 8618, 6180\nvar b = new AshlarSdkBuilder(options);\n", null)] + public void A_deprecation_disable_is_judged_on_name_liveness_and_reason(string source, string? expected) + { + var violations = Rule2(source, ForwarderAsShipped, ExperimentalDeclarations); + + if (expected is null) + violations.Should().BeEmpty("this disable names a live symbol and says why:\n{0}", source); + else + violations.Should().ContainSingle("source:\n{0}", source).Which.Message.Should().Contain(expected); + } + + [Theory] + [InlineData("[SuppressMessage(\"Usage\", \"CS0618:Type or member is obsolete\", Justification = \"AshlarSdkBuilder forwarding\")]\npublic void M() { }", true)] + [InlineData("[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(\"Usage\", \"ASHLAREXP001\")]", true)] + [InlineData("[UnconditionalSuppressMessage(\"Usage\",\n \"cs0618\")]\npublic void M() { }", true)] + [InlineData("[SuppressMessage(\"Usage\", \"CS8618:Non-nullable field\")]\npublic void M() { }", false)] + [InlineData("[SuppressMessage(\"Usage\", \"ASHLAREXP0010\")]\npublic void M() { }", false)] + [InlineData("// [SuppressMessage(\"Usage\", \"CS0618\")]\npublic void M() { }", false)] + [InlineData("var s = \"[SuppressMessage(\\\"Usage\\\", \\\"CS0618\\\")]\";", false)] + public void A_suppress_attribute_naming_a_deprecation_id_is_caught_and_only_then(string source, bool caught) + { + var problems = DeprecationSuppressAttributeViolations(SourceFile.Parse("fixture/A.cs", source)).ToList(); + (problems.Count > 0).Should().Be(caught, "source:\n{0}\nproblems: {1}", source, + string.Join("; ", problems.Select(p => p.Message))); + } + + [Theory] + // Every spelling of the id the compiler accepts, in an element or an attribute, in either property. + [InlineData("$(NoWarn);CS0618", true)] + [InlineData("$(NoWarn);618", true)] + [InlineData("0618,1591", true)] + [InlineData("$(NoWarn);ashlarexp001", true)] + [InlineData("$(WarningsNotAsErrors);CS0618", true)] + [InlineData("", true)] + // Other ids, and settings that strengthen rather than disable. + [InlineData("$(NoWarn);1591;CS8618;RS0036;RS0037", false)] + [InlineData("$(NoWarn);6180;ASHLAREXP0010;CS06180", false)] + [InlineData("CS0618;ASHLAREXP001", false)] + [InlineData("", false)] + public void An_msbuild_disable_of_a_deprecation_id_is_caught_and_only_then(string xml, bool caught) + { + var problems = DeprecationMsBuildDisables("fixture/A.csproj", XDocument.Parse(xml)).ToList(); + (problems.Count > 0).Should().Be(caught, "xml:\n{0}\nproblems: {1}", xml, string.Join("; ", problems.Select(p => p.Message))); + } + + [Theory] + [InlineData("[*.cs]\ndotnet_diagnostic.CS0618.severity = none", true)] + [InlineData("[*.cs]\ndotnet_diagnostic.cs0618.severity=silent", true)] + [InlineData("is_global = true\ndotnet_diagnostic.ASHLAREXP001.severity = suggestion", true)] + [InlineData("[*.cs]\ndotnet_diagnostic.CS0618.severity = error", false)] + [InlineData("[*.cs]\n# dotnet_diagnostic.CS0618.severity = none", false)] + [InlineData("[*.cs]\ndotnet_diagnostic.CS8618.severity = none", false)] + public void An_analyzer_config_disable_of_a_deprecation_id_is_caught_and_only_then(string text, bool caught) + { + var problems = DeprecationAnalyzerConfigDisables("fixture/.editorconfig", text).ToList(); + (problems.Count > 0).Should().Be(caught, "config:\n{0}\nproblems: {1}", text, string.Join("; ", problems.Select(p => p.Message))); + } + + [Fact] + public void The_lexer_blanks_comments_and_literals_and_keeps_every_line() + { + const string source = "var a = \"x // y\"; // tail\n/* block\n spans */ var b = 'c';\n#if DEBUG // note\nvar c = @\"v\"\"w\";\n#endif\n"; + var file = SourceFile.Parse("fixture/A.cs", source); + + file.Bare.Length.Should().Be(file.Text.Length); + file.Bare.Split('\n').Length.Should().Be(file.Text.Split('\n').Length); + file.Bare.Should().NotContain("tail").And.NotContain("block").And.NotContain("x //").And.NotContain("note"); + file.Bare.Should().Contain("var a =").And.Contain("var b =").And.Contain("#if DEBUG").And.Contain("var c ="); + file.Code.Should().Contain("\"x // y\"", "Code keeps literals, which the attribute reader needs"); + file.Code.Should().NotContain("tail"); + } + + private static List Rule2(string source, params string[] declarationFiles) + { + var files = new List { SourceFile.Parse("fixture/Subject.cs", source) }; + files.AddRange(declarationFiles.Select((text, i) => SourceFile.Parse($"fixture/Decl{i}.cs", text))); + var symbols = DeclaredSymbols.Collect(files); + return DeprecationSuppressionViolations(files[0], symbols).ToList(); + } + + // ------------------------------------------------------------------------------------------------ + // Rule 1 matchers + // ------------------------------------------------------------------------------------------------ + + private static IEnumerable ApiTrackingSourceViolations(SourceFile file) + { + foreach (var pragma in Pragmas(file).Where(p => p.Disable)) + { + if (pragma.Ids.Count == 0) + { + yield return $"{file.Path}:{pragma.Line}: a bare '#pragma warning disable' switches off every diagnostic " + + "in the file, public-API tracking and CS0618/ASHLAREXP001 included; name the ids"; + continue; + } + + foreach (var id in pragma.Ids.Where(id => ApiTrackingId.IsMatch(id) && id.Length == 6)) + yield return $"{file.Path}:{pragma.Line}: '#pragma warning disable {id}' switches off public-API tracking"; + } + + foreach (Match m in SuppressAttribute.Matches(file.Bare)) + { + var open = m.Index + m.Length - 1; + var close = MatchForward(file.Bare, open, '(', ')'); + var args = file.Code[open..(close < 0 ? file.Code.Length : close + 1)]; + if (ApiTrackingId.IsMatch(args)) + yield return $"{file.Path}:{LineOf(file, m.Index)}: a SuppressMessage attribute switches off public-API tracking " + + $"({ApiTrackingId.Match(args).Value})"; + } + } + + private static IEnumerable ApiTrackingMsBuildViolations(string path, XDocument doc, bool governsApiProject) + { + foreach (var element in doc.Descendants()) + { + var name = element.Name.LocalName; + if ((name is "NoWarn" or "WarningsNotAsErrors") && ApiTrackingId.IsMatch(element.Value)) + yield return $"{path}: <{name}> lists {ApiTrackingId.Match(element.Value).Value}, which switches off public-API tracking"; + + foreach (var attribute in element.Attributes().Where(a => a.Name.LocalName is "NoWarn" or "WarningsNotAsErrors")) + { + if (ApiTrackingId.IsMatch(attribute.Value)) + yield return $"{path}: {name} {attribute.Name.LocalName}=\"{attribute.Value}\" lists a public-API tracking id"; + } + + if (governsApiProject && (name is "RunAnalyzers" or "RunAnalyzersDuringBuild") + && string.Equals(element.Value.Trim(), "false", StringComparison.OrdinalIgnoreCase)) + yield return $"{path}: <{name}>false switches off every analyzer, public-API tracking included, " + + "for a project that references " + ApiAnalyzerPackage; + + var removed = (string?)element.Attribute("Remove") ?? ""; + if (governsApiProject && name == "AdditionalFiles" && removed.Contains("PublicAPI", StringComparison.OrdinalIgnoreCase)) + yield return $"{path}: takes an API file away from the analyzer"; + } + } + + private static IEnumerable ApiTrackingAnalyzerConfigViolations(string path, string text, bool appliesToApiProject) + { + var lines = text.Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (line.Length == 0 || line[0] is '#' or ';') + continue; + + var diagnostic = DiagnosticSeverity.Match(line); + if (diagnostic.Success && ApiTrackingId.IsMatch(diagnostic.Groups["id"].Value) + && diagnostic.Groups["id"].Value.Length == 6 && WeakSeverity.IsMatch(diagnostic.Groups["sev"].Value)) + yield return $"{path}:{i + 1}: '{line}' switches off public-API tracking"; + + if (SkipNamespaces.IsMatch(line)) + yield return $"{path}:{i + 1}: '{line}' tells the analyzer to skip a namespace, which untracks its surface"; + + var analyzer = AnalyzerSeverity.Match(line); + if (appliesToApiProject && analyzer.Success && WeakSeverity.IsMatch(analyzer.Groups["sev"].Value) + && (!analyzer.Groups["cat"].Success + || string.Equals(analyzer.Groups["cat"].Value, "ApiDesign", StringComparison.OrdinalIgnoreCase))) + yield return $"{path}:{i + 1}: '{line}' applies to a project that references {ApiAnalyzerPackage} " + + "and switches off its ApiDesign rules"; + } + } + + // ------------------------------------------------------------------------------------------------ + // Rule 2 matcher + // ------------------------------------------------------------------------------------------------ + + private sealed record Violation(string Path, string Pragma, string Message); + + private static IEnumerable DeprecationSuppressionViolations(SourceFile file, DeclaredSymbols symbols) + { + foreach (var pragma in Pragmas(file).Where(p => p.Disable)) + { + var problems = new List(); + foreach (var id in pragma.Ids.Where(id => id is ObsoleteId or ExperimentalId).Distinct()) + { + var obsolete = id == ObsoleteId; + var declared = obsolete ? symbols.Obsolete : symbols.Experimental; + var attribute = obsolete ? "[Obsolete]" : $"[Experimental({ExperimentalId})]"; + if (pragma.Comment.Length == 0) + { + problems.Add($"{id}: carries no comment; name the {attribute} symbol it is for and say why"); + continue; + } + + var named = Identifier.Matches(pragma.Comment).Select(m => m.Value.TrimStart('@')) + .Where(declared.Contains).Distinct(StringComparer.Ordinal).ToList(); + if (named.Count == 0) + { + problems.Add($"{id}: names no in-tree {attribute} symbol (an exact identifier, e.g. TouchSet, not 'touch-set')"); + continue; + } + + var spans = obsolete ? file.ObsoleteSpans : file.ExperimentalSpans; + if (!named.Any(symbol => IsUsed(file, symbol, pragma.RegionStart, RegionEnd(file, pragma, id), spans))) + problems.Add($"{id}: is dead - {string.Join(", ", named)} is not used outside its own {attribute} context " + + "between this line and its restore (or the end of the file)"); + + var reason = ReasonWord.Matches(pragma.Comment).Select(m => m.Value) + .Count(w => !named.Contains(w, StringComparer.Ordinal)); + if (reason < MinimumReasonWords) + problems.Add($"{id}: gives no reason ({reason} word(s) besides the names; at least {MinimumReasonWords})"); + } + + if (problems.Count > 0) + yield return new Violation(file.Path, pragma.Text, + $"{file.Path}:{pragma.Line}: '{pragma.Text}' - {string.Join("; ", problems)}"); + } + } + + /// + /// A [SuppressMessage]/[UnconditionalSuppressMessage] naming CS0618 or ASHLAREXP001. It has no place + /// for the named symbol and reason Rule 2 requires, so it is refused whether or not a given compiler + /// honours it for a compiler diagnostic: a scoped pragma says the same thing and can be checked. + /// + private static IEnumerable DeprecationSuppressAttributeViolations(SourceFile file) + { + foreach (Match m in SuppressAttribute.Matches(file.Bare)) + { + var open = m.Index + m.Length - 1; + var close = MatchForward(file.Bare, open, '(', ')'); + var end = close < 0 ? file.Code.Length : close + 1; + var id = DeprecationIdInText.Match(file.Code[open..end]); + if (!id.Success) + continue; + + var text = file.Code[m.Index..end].Trim(); + yield return new Violation(file.Path, text, + $"{file.Path}:{LineOf(file, m.Index)}: a SuppressMessage attribute disables {id.Value.ToUpperInvariant()}; " + + "it cannot name the symbol it is for or say why - use a scoped '#pragma warning disable' that does (Rule 2)"); + } + } + + /// + /// NoWarn / WarningsNotAsErrors, as an element or an attribute, listing CS0618 (any spelling the + /// compiler accepts: CS0618, 618, 0618, any case) or ASHLAREXP001. The key is the + /// exact setting text, so a reviewed entry survives a line move but not an edit. + /// + private static IEnumerable DeprecationMsBuildDisables(string path, XDocument doc) + { + foreach (var element in doc.Descendants()) + { + var name = element.Name.LocalName; + if (name is ("NoWarn" or "WarningsNotAsErrors") && !element.HasElements) + { + var ids = DeprecationIdsInList(element.Value); + if (ids.Count > 0) + { + var setting = $"<{name}>{element.Value.Trim()}"; + yield return new Violation(path, setting, + $"{path}{LineSuffix(element)}: '{setting}' {Effect(name)} {string.Join(", ", ids)} project-wide"); + } + } + + foreach (var attribute in element.Attributes().Where(a => a.Name.LocalName is "NoWarn" or "WarningsNotAsErrors")) + { + var ids = DeprecationIdsInList(attribute.Value); + if (ids.Count > 0) + { + var setting = $"{name} {attribute.Name.LocalName}=\"{attribute.Value}\""; + yield return new Violation(path, setting, + $"{path}{LineSuffix(element)}: '{setting}' {Effect(attribute.Name.LocalName)} {string.Join(", ", ids)} project-wide"); + } + } + } + + static string Effect(string property) => + property == "NoWarn" ? "disables" : "keeps TreatWarningsAsErrors from failing on"; + + static string LineSuffix(XElement element) + { + var info = (System.Xml.IXmlLineInfo)element; + return info.HasLineInfo() ? $":{info.LineNumber}" : ""; + } + } + + /// A dotnet_diagnostic.<id>.severity of none, silent or suggestion for CS0618 or ASHLAREXP001. + private static IEnumerable DeprecationAnalyzerConfigDisables(string path, string text) + { + var lines = text.Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (line.Length == 0 || line[0] is '#' or ';') + continue; + + var diagnostic = DiagnosticSeverity.Match(line); + if (diagnostic.Success && NormalizeId(diagnostic.Groups["id"].Value) is ObsoleteId or ExperimentalId + && WeakSeverity.IsMatch(diagnostic.Groups["sev"].Value)) + yield return new Violation(path, line, $"{path}:{i + 1}: '{line}' disables {NormalizeId(diagnostic.Groups["id"].Value)} " + + "for every file the config applies to"); + } + } + + private static List DeprecationIdsInList(string list) => + list.Split([';', ',', ' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(NormalizeId) + .Where(id => id is ObsoleteId or ExperimentalId) + .Distinct(StringComparer.Ordinal) + .ToList(); + + /// + /// A warning id as the compiler compares it: case-insensitively, with a bare number read as a C# + /// compiler warning. Roslyn maps a numeric id to "CS" plus the number padded to four digits, both for + /// a #pragma warning and for /nowarn (so 618 and 0618 are CS0618). + /// + private static string NormalizeId(string id) + { + var trimmed = id.Trim(); + if (trimmed.Length > 0 && trimmed.All(char.IsAsciiDigit) + && int.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out var number)) + return "CS" + number.ToString("0000", CultureInfo.InvariantCulture); + return trimmed.ToUpperInvariant(); + } + + private static bool IsUsed(SourceFile file, string symbol, int start, int end, IReadOnlyList<(int Start, int End)> spans) + { + if (end <= start) + return false; + var pattern = new Regex(@"(? m.Index >= s.Start && m.Index < s.End)) + return true; + } + + return false; + } + + private static int RegionEnd(SourceFile file, Pragma disable, string id) + { + var restore = Pragmas(file).FirstOrDefault(p => !p.Disable && p.Line > disable.Line && (p.Ids.Count == 0 || p.Ids.Contains(id))); + return restore is null ? file.Bare.Length : file.LineStarts[restore.Line - 1]; + } + + private static bool IsDirectiveLine(SourceFile file, int offset) + { + var i = file.LineStarts[LineOf(file, offset) - 1]; + while (i < file.Bare.Length && file.Bare[i] is ' ' or '\t') + i++; + return i < file.Bare.Length && file.Bare[i] == '#'; + } + + // ------------------------------------------------------------------------------------------------ + // Pragmas, declarations, lexing + // ------------------------------------------------------------------------------------------------ + + private sealed record Pragma(string Path, int Line, bool Disable, IReadOnlyList Ids, string Comment, string Text, int RegionStart); + + private static IEnumerable Pragmas(SourceFile file) + { + var bareLines = file.Bare.Split('\n'); + var textLines = file.Text.Split('\n'); + for (var i = 0; i < bareLines.Length; i++) + { + var m = PragmaLine.Match(bareLines[i]); + if (!m.Success) + continue; + + var ids = m.Groups[2].Value.Split([',', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries) + .Select(NormalizeId).ToList(); + var original = textLines[i]; + var slash = original.IndexOf("//", StringComparison.Ordinal); + var comment = slash < 0 ? "" : original[(slash + 2)..].Trim(); + var regionStart = i + 1 < file.LineStarts.Length ? file.LineStarts[i + 1] : file.Bare.Length; + yield return new Pragma(file.Path, i + 1, m.Groups[1].Value == "disable", ids, comment, original.Trim(), regionStart); + } + } + + private sealed class DeclaredSymbols + { + public HashSet Obsolete { get; } = new(StringComparer.Ordinal); + public HashSet Experimental { get; } = new(StringComparer.Ordinal); + + public static DeclaredSymbols Collect(IEnumerable files) + { + var symbols = new DeclaredSymbols(); + foreach (var file in files) + { + foreach (var declaration in file.Declarations) + { + if (declaration.Name is null) + continue; + (declaration.Obsolete ? symbols.Obsolete : symbols.Experimental).Add(declaration.Name); + } + } + + return symbols; + } + } + + private sealed record Declaration(string? Name, bool Obsolete, int Start, int End); + + private sealed class SourceFile + { + private SourceFile(string path, string text, string code, string bare) + { + Path = path; + Text = text; + Code = code; + Bare = bare; + var starts = new List { 0 }; + for (var i = 0; i < text.Length; i++) + { + if (text[i] == '\n') + starts.Add(i + 1); + } + + LineStarts = starts.ToArray(); + Declarations = ReadDeclarations(this).ToList(); + ObsoleteSpans = Declarations.Where(d => d.Obsolete).Select(d => (d.Start, d.End)).ToList(); + ExperimentalSpans = Declarations.Where(d => !d.Obsolete).Select(d => (d.Start, d.End)).ToList(); + } + + public string Path { get; } + + /// The source, CRLF normalised to LF. + public string Text { get; } + + /// Comments blanked, literals kept; same length and line breaks as . + public string Code { get; } + + /// Comments AND literal contents blanked; same length and line breaks as . + public string Bare { get; } + + public int[] LineStarts { get; } + + /// Declarations carrying a CS0618 [Obsolete] or an ASHLAREXP001 [Experimental]. + public IReadOnlyList Declarations { get; } + + public IReadOnlyList<(int Start, int End)> ObsoleteSpans { get; } + + public IReadOnlyList<(int Start, int End)> ExperimentalSpans { get; } + + public static SourceFile Parse(string path, string raw) + { + var text = raw.Replace("\r\n", "\n"); + var (code, bare) = Lex(text); + return new SourceFile(path, text, code, bare); + } + } + + private static IEnumerable ReadDeclarations(SourceFile file) + { + var bare = file.Bare; + foreach (Match m in DeprecationAttribute.Matches(bare)) + { + var sectionOpen = AttributeSectionStart(bare, m.Index); + if (sectionOpen < 0) + continue; + var sectionClose = MatchForward(bare, sectionOpen, '[', ']'); + if (sectionClose < 0) + continue; + + var after = m.Index + m.Length; + while (after < bare.Length && char.IsWhiteSpace(bare[after])) + after++; + var argsBare = ""; + var argsCode = ""; + if (after < bare.Length && bare[after] == '(') + { + var close = MatchForward(bare, after, '(', ')'); + if (close > 0) + { + argsBare = bare[after..(close + 1)]; + argsCode = file.Code[after..(close + 1)]; + } + } + + bool obsolete; + if (m.Groups["kind"].Value == "Obsolete") + { + // error: true makes it CS0619; a DiagnosticId replaces CS0618. Read on the blanked text so + // a message containing the word "true" cannot flip it. + if (Regex.IsMatch(argsBare, @"\btrue\b") || Regex.IsMatch(argsBare, @"\bDiagnosticId\s*=")) + continue; + obsolete = true; + } + else + { + if (!argsCode.Contains("AutonomyExperimental.DiagnosticId", StringComparison.Ordinal) + && !argsCode.Contains("\"" + ExperimentalId + "\"", StringComparison.Ordinal)) + continue; + obsolete = false; + } + + var (name, end) = ReadDeclaration(bare, sectionClose + 1); + yield return new Declaration(name, obsolete, sectionOpen, end); + } + } + + /// + /// Walks back from an attribute name to the '[' that opens its section, over earlier attributes in + /// the same section and an attribute target. -1 when the name is not in an attribute section, or the + /// section targets the assembly, module, a return value or a parameter. + /// + private static int AttributeSectionStart(string bare, int nameStart) + { + var p = nameStart - 1; + while (p >= 0) + { + var c = bare[p]; + if (char.IsWhiteSpace(c)) + { + p--; + continue; + } + + if (c == '[') + return p; + + if (c == ':') + { + if (p > 0 && bare[p - 1] == ':') + return -1; + p--; + while (p >= 0 && char.IsWhiteSpace(bare[p])) + p--; + var end = p; + while (p >= 0 && IsIdentChar(bare[p])) + p--; + var target = bare[(p + 1)..(end + 1)]; + if (target is "assembly" or "module" or "return" or "param" or "typevar") + return -1; + continue; + } + + if (c == ',') + { + p--; + while (p >= 0 && char.IsWhiteSpace(bare[p])) + p--; + if (p >= 0 && bare[p] == ')') + { + p = MatchBackward(bare, p, '(', ')') - 1; + while (p >= 0 && char.IsWhiteSpace(bare[p])) + p--; + } + + while (p >= 0 && (IsIdentChar(bare[p]) || bare[p] == '.' || bare[p] == ':')) + p--; + continue; + } + + return -1; + } + + return -1; + } + + /// + /// Reads the declaration after an attribute section: its name (null when it has none this reader + /// understands - an operator, an indexer, a tuple-returning member) and the end of its extent, so a + /// use inside it can be told from a use outside it. + /// + private static (string? Name, int End) ReadDeclaration(string bare, int from) + { + var n = bare.Length; + var i = from; + while (true) + { + while (i < n && char.IsWhiteSpace(bare[i])) + i++; + if (i < n && bare[i] == '#') + { + i = LineEnd(bare, i); + continue; + } + + if (i < n && bare[i] == '[') + { + var close = MatchForward(bare, i, '[', ']'); + if (close < 0) + return (null, n); + i = close + 1; + continue; + } + + break; + } + + var headerStart = i; + var angle = 0; + while (i < n) + { + var c = bare[i]; + if (c is '{' or ';') + break; + if (c == '<') + angle++; + else if (c == '>' && angle > 0) + angle--; + else if (angle == 0 && c is '=' or '(' or ',' or ')' or '}') + break; + i++; + } + + var header = bare[headerStart..Math.Min(i, n)]; + var tokens = Identifier.Matches(StripGenerics(header)).Select(t => t.Value.TrimStart('@')).ToList(); + var typeKeyword = tokens.FindIndex(t => t is "class" or "struct" or "interface" or "enum" or "record"); + if (typeKeyword >= 0) + { + var at = typeKeyword + 1; + if (tokens[typeKeyword] == "record" && at < tokens.Count && tokens[at] is "class" or "struct") + at++; + var typeName = at < tokens.Count ? tokens[at] : null; + var body = ScanToBodyOrSemicolon(bare, headerStart); + return (typeName, body); + } + + string? name = null; + if (tokens.Count > 0 && !tokens.Contains("operator") && !tokens.Contains("this")) + name = NotANameKeywords.Contains(tokens[^1]) ? null : tokens[^1]; + + return (name, MemberEnd(bare, i)); + } + + private static int ScanToBodyOrSemicolon(string bare, int from) + { + var depth = 0; + for (var i = from; i < bare.Length; i++) + { + var c = bare[i]; + if (c == '(') + depth++; + else if (c == ')') + depth--; + else if (depth == 0 && c == ';') + return i + 1; + else if (depth == 0 && c == '{') + { + var close = MatchForward(bare, i, '{', '}'); + return close < 0 ? bare.Length : close + 1; + } + } + + return bare.Length; + } + + private static int MemberEnd(string bare, int stop) + { + if (stop >= bare.Length) + return bare.Length; + switch (bare[stop]) + { + case '{': + { + var close = MatchForward(bare, stop, '{', '}'); + if (close < 0) + return bare.Length; + var j = close + 1; + while (j < bare.Length && char.IsWhiteSpace(bare[j])) + j++; + return j < bare.Length && bare[j] == '=' ? StatementEnd(bare, j) : close + 1; + } + case '(': + return ScanToBodyOrSemicolon(bare, stop); + case '=': + return StatementEnd(bare, stop); + case ';': + return stop + 1; + default: + return stop; + } + } + + private static int StatementEnd(string bare, int from) + { + var depth = 0; + for (var i = from; i < bare.Length; i++) + { + var c = bare[i]; + if (c is '(' or '{' or '[') + depth++; + else if (c is ')' or '}' or ']') + depth--; + else if (depth <= 0 && c == ';') + return i + 1; + } + + return bare.Length; + } + + private static string StripGenerics(string header) + { + var previous = ""; + while (previous != header) + { + previous = header; + header = Regex.Replace(header, "<[^<>]*>", " "); + } + + return header; + } + + /// + /// Splits C# into two views of the same length and line breaks: Code (comments blanked) and Bare + /// (comments and literal contents blanked; interpolation holes kept, since they are code). A + /// preprocessor line keeps its directive and loses only a trailing // comment. + /// + private static (string Code, string Bare) Lex(string text) + { + var code = text.ToCharArray(); + var bare = text.ToCharArray(); + var n = text.Length; + var i = 0; + var atLineStart = true; + while (i < n) + { + var c = text[i]; + if (c == '\n') + { + atLineStart = true; + i++; + continue; + } + + if (char.IsWhiteSpace(c)) + { + i++; + continue; + } + + if (atLineStart && c == '#') + { + var end = LineEnd(text, i); + var comment = text.IndexOf("//", i, end - i, StringComparison.Ordinal); + if (comment >= 0) + { + Blank(code, comment, end); + Blank(bare, comment, end); + } + + i = end; + continue; + } + + atLineStart = false; + if (c == '/' && i + 1 < n && text[i + 1] == '/') + { + var end = LineEnd(text, i); + Blank(code, i, end); + Blank(bare, i, end); + i = end; + continue; + } + + if (c == '/' && i + 1 < n && text[i + 1] == '*') + { + var close = text.IndexOf("*/", i + 2, StringComparison.Ordinal); + var end = close < 0 ? n : close + 2; + Blank(code, i, end); + Blank(bare, i, end); + i = end; + continue; + } + + if (c == '\'') + { + var j = i + 1; + while (j < n && text[j] != '\'' && text[j] != '\n') + j += text[j] == '\\' ? 2 : 1; + j = Math.Min(j, n); + Blank(bare, i + 1, j); + i = Math.Min(j + 1, n); + continue; + } + + if (c == '"' || (c is '$' or '@' && StartsString(text, i))) + { + i = SkipString(text, i, bare); + continue; + } + + if (IsIdentChar(c)) + { + while (i < n && IsIdentChar(text[i])) + i++; + continue; + } + + i++; + } + + return (new string(code), new string(bare)); + } + + private static bool StartsString(string text, int i) + { + var j = i; + while (j < text.Length && text[j] is '$' or '@') + j++; + return j > i && j < text.Length && text[j] == '"'; + } + + private static int SkipString(string text, int i, char[] bare) + { + var n = text.Length; + var dollars = 0; + var verbatim = false; + while (text[i] is '$' or '@') + { + if (text[i] == '$') + dollars++; + else + verbatim = true; + i++; + } + + var quotes = 0; + while (i + quotes < n && text[i + quotes] == '"') + quotes++; + + if (!verbatim && quotes >= 3) + { + var start = i + quotes; + var k = start; + while (k < n) + { + if (text[k] == '"') + { + var run = 0; + while (k + run < n && text[k + run] == '"') + run++; + if (run >= quotes) + { + BlankLiteral(text, bare, start, k, dollars, raw: true); + return k + quotes; + } + + k += run; + continue; + } + + k++; + } + + BlankLiteral(text, bare, start, n, dollars, raw: true); + return n; + } + + var begin = i + 1; + var e = begin; + while (e < n) + { + var ch = text[e]; + if (!verbatim && ch == '\n') + break; + if (!verbatim && ch == '\\') + { + e += 2; + continue; + } + + if (ch == '"') + { + if (verbatim && e + 1 < n && text[e + 1] == '"') + { + e += 2; + continue; + } + + break; + } + + if (dollars > 0 && ch == '{' && !(e + 1 < n && text[e + 1] == '{')) + { + var depth = 0; + while (e < n) + { + if (text[e] == '{') + depth++; + else if (text[e] == '}' && --depth == 0) + break; + else if (!verbatim && text[e] == '\n') + break; + e++; + } + + e++; + continue; + } + + e++; + } + + e = Math.Min(e, n); + BlankLiteral(text, bare, begin, e, dollars, raw: false); + return Math.Min(e + 1, n); + } + + /// Blanks a literal's text in Bare, keeping interpolation holes (they are code). + private static void BlankLiteral(string text, char[] bare, int start, int end, int dollars, bool raw) + { + if (dollars == 0) + { + Blank(bare, start, end); + return; + } + + var open = raw ? dollars : 1; + var k = start; + while (k < end) + { + if (text[k] == '{') + { + var run = 0; + while (k + run < end && text[k + run] == '{') + run++; + if (!raw && run >= 2) + { + Blank(bare, k, k + (run / 2 * 2)); + k += run / 2 * 2; + continue; + } + + if (run >= open) + { + Blank(bare, k, k + run); + k += run; + var depth = 1; + while (k < end && depth > 0) + { + if (text[k] == '{') + depth++; + else if (text[k] == '}') + depth--; + if (depth > 0) + k++; + } + + var closeRun = 0; + while (k + closeRun < end && text[k + closeRun] == '}') + closeRun++; + Blank(bare, k, k + Math.Max(closeRun, 1)); + k += Math.Max(closeRun, 1); + continue; + } + } + + if (text[k] != '\n') + bare[k] = ' '; + k++; + } + } + + private static void Blank(char[] chars, int start, int end) + { + for (var k = Math.Max(0, start); k < Math.Min(end, chars.Length); k++) + { + if (chars[k] != '\n') + chars[k] = ' '; + } + } + + private static int MatchForward(string s, int open, char openChar, char closeChar) + { + var depth = 0; + for (var i = open; i < s.Length; i++) + { + if (s[i] == openChar) + depth++; + else if (s[i] == closeChar && --depth == 0) + return i; + } + + return -1; + } + + private static int MatchBackward(string s, int close, char openChar, char closeChar) + { + var depth = 0; + for (var i = close; i >= 0; i--) + { + if (s[i] == closeChar) + depth++; + else if (s[i] == openChar && --depth == 0) + return i; + } + + return 0; + } + + private static int LineEnd(string s, int from) + { + var end = s.IndexOf('\n', from); + return end < 0 ? s.Length : end; + } + + private static int LineOf(SourceFile file, int offset) + { + var index = Array.BinarySearch(file.LineStarts, offset); + return index >= 0 ? index + 1 : ~index; + } + + private static bool IsIdentChar(char c) => char.IsLetterOrDigit(c) || c == '_'; + + private static string DirectoryOf(string relativePath) + { + var slash = relativePath.LastIndexOf('/'); + return slash < 0 ? "" : relativePath[..slash]; + } + + // ------------------------------------------------------------------------------------------------ + // The tree + // ------------------------------------------------------------------------------------------------ + + private sealed class RepoTree + { + private RepoTree(string root) => Root = root; + + public string Root { get; } + public List Sources { get; } = []; + public List<(string Path, XDocument Doc)> MsBuild { get; } = []; + public List<(string Path, string Text)> AnalyzerConfigs { get; } = []; + public List ApiProjects { get; } = []; + public DeclaredSymbols Symbols { get; private set; } = new(); + + public static RepoTree Load(string root) + { + var tree = new RepoTree(root); + foreach (var file in Walk(root, root)) + { + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + var name = Path.GetFileName(file); + if (name.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + tree.Sources.Add(SourceFile.Parse(relative, File.ReadAllText(file))); + else if (name.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".props", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".targets", StringComparison.OrdinalIgnoreCase)) + tree.MsBuild.Add((relative, XDocument.Load(file, LoadOptions.SetLineInfo))); + else if (name == ".editorconfig" || name.EndsWith(".globalconfig", StringComparison.OrdinalIgnoreCase)) + tree.AnalyzerConfigs.Add((relative, File.ReadAllText(file))); + } + + foreach (var (path, doc) in tree.MsBuild) + { + if (path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) + && doc.Descendants().Any(e => e.Name.LocalName == "PackageReference" + && string.Equals((string?)e.Attribute("Include"), ApiAnalyzerPackage, StringComparison.OrdinalIgnoreCase))) + tree.ApiProjects.Add(path); + } + + tree.Symbols = DeclaredSymbols.Collect(tree.Sources); + return tree; + } + + /// An analyzer project's file, or a Directory.Build.* on the chain above one. + public bool GovernsApiProject(string path) + { + if (ApiProjects.Contains(path)) + return true; + var name = path[(path.LastIndexOf('/') + 1)..]; + if (name is not ("Directory.Build.props" or "Directory.Build.targets")) + return false; + var dir = DirectoryOf(path); + return ApiProjects.Any(p => IsSameOrUnder(DirectoryOf(p), dir)); + } + + /// A .globalconfig anywhere, or an .editorconfig above, at or below an analyzer project. + public bool ConfigAppliesToApiProject(string path) + { + if (path.EndsWith(".globalconfig", StringComparison.OrdinalIgnoreCase)) + return true; + var dir = DirectoryOf(path); + return ApiProjects.Select(DirectoryOf).Any(p => IsSameOrUnder(p, dir) || IsSameOrUnder(dir, p)); + } + + private static bool IsSameOrUnder(string path, string ancestor) => + ancestor.Length == 0 || path == ancestor || path.StartsWith(ancestor + "/", StringComparison.Ordinal); + + /// Build output, dependency caches, dot-directories and nested checkouts are not the tree. + private static IEnumerable Walk(string root, string directory) + { + foreach (var file in Directory.EnumerateFiles(directory)) + yield return file; + + foreach (var child in Directory.EnumerateDirectories(directory)) + { + var name = Path.GetFileName(child); + if (name.StartsWith('.') || name is "bin" or "obj" or "node_modules" or "TestResults" + || File.Exists(Path.Combine(child, ".git")) || Directory.Exists(Path.Combine(child, ".git"))) + continue; + + foreach (var file in Walk(root, child)) + yield return file; + } + } + } +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/MediatedWritePathTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/MediatedWritePathTests.cs index 51fc65bf7..15c4136d7 100755 --- a/src/Ashlar.Tests.Infrastructure/Tests/Certification/MediatedWritePathTests.cs +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/MediatedWritePathTests.cs @@ -201,6 +201,38 @@ public void Refuse_AllowsAnOrdinaryContentPath() public void Refuse_RejectsGovernanceAndEscapes(string target) => MediatedWritePath.Refuse(_root, target).Should().NotBeNull(); + /// + /// The containment leg is PathContainment.IsStrictlyWithin with an ordinal comparison: + /// the root itself is not a write target however it is spelled (a trailing separator included), + /// a sibling that merely shares the root's name is an escape, and a spelling that differs from + /// the root only by letter case is refused on every platform rather than judged. + /// + [Theory] + [InlineData(".")] + [InlineData("./")] + [InlineData("a/..")] + [InlineData("a/../")] + public void Refuse_TreatsTheRootItselfAsAnEscape(string target) + => MediatedWritePath.Refuse(_root, target).Should().Contain("escapes the project root"); + + [Fact] + public void Refuse_TreatsASiblingThatSharesTheRootsNameAsAnEscape() + { + var sibling = "../" + Path.GetFileName(_root) + "-evil/x.cs"; + Path.GetFullPath(Path.Combine(_root, sibling)).Should().StartWith(_root, "the sibling must share the root's characters"); + + MediatedWritePath.Refuse(_root, sibling).Should().Contain("escapes the project root"); + } + + [Fact] + public void Refuse_TreatsACaseVariantOfTheRootAsAnEscapeOnEveryPlatform() + { + var name = Path.GetFileName(_root); + name.ToUpperInvariant().Should().NotBe(name, "the variant must differ from the root ordinally"); + + MediatedWritePath.Refuse(_root, "../" + name.ToUpperInvariant() + "/x.cs").Should().Contain("escapes the project root"); + } + [Fact] public void Refuse_Allowlist_RejectsOutside_AdmitsInside_AndHonoursEveryEntry() { diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/PathContainmentConventionTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/PathContainmentConventionTests.cs new file mode 100644 index 000000000..6fb592e85 --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/PathContainmentConventionTests.cs @@ -0,0 +1,1009 @@ +using System.Text.RegularExpressions; +using FluentAssertions; +using Ashlar.Core.Application.Paths; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// +/// No production file hand-rolls a path-containment check; PathContainment is the one place. +/// +/// Why this blocks a merge. "Is this path inside that root?" is two lines, so every new +/// tool re-derived it. Ten copies had drifted into five semantics — with and without a separator +/// after the root, with and without the root itself, case-folding everywhere, nowhere, or on some +/// platforms — and the one without a separator (OutputPathSandboxed) approved +/// .../out-evil as inside .../out on a live path. The next copy is written the same way +/// unless something stops it. +/// +/// A per-file ratchet, in both directions. Every production file's count of hand-rolled +/// checks must EQUAL its pin in (an unlisted file is pinned at zero). One +/// more fails as a new copy; one fewer fails as a stale pin, so converting a copy forces the pin down +/// in the same diff. A repo-wide total would let a new copy hide behind a conversion elsewhere. +/// +/// How the scan tells a containment check from any other StartsWith. Comments +/// and string contents are blanked first, so documentation and code templates never count; the +/// holes of an interpolated string are kept as expressions. An expression has FULL-PATH PROVENANCE +/// when it spells GetFullPath(, DirectorySeparatorChar, AltDirectorySeparatorChar +/// or new FileInfo(/new DirectoryInfo(, or names an identifier that has it. An +/// identifier gets it in three ways, repeated until nothing changes: +/// +/// assigned from an expression that spells one of those marks; +/// assigned ONE PATH-BUILDING STEP from an identifier that has it: Path.Combine or +/// Path.Join, a concatenated separator literal (root + "/"), an interpolated string +/// with a separator in its text ($"{root}/"), or a plain alias (var r = root;, +/// optionally trimmed). Any other transformation drops it: full.Substring(root.Length) is a +/// RELATIVE path, which is how MediatedWritePath.IsUnderAllowlist stays out; +/// as a PARAMETER of a method declared in the same file, when a call in the same file passes +/// that parameter (by position or by name) an argument that has it. This is what sees a +/// separator-less helper such as Inside(string here, string there) => here.StartsWith(there) +/// fed full paths by its own file. +/// +/// A .StartsWith( call then counts when its first argument is NOT a literal and its +/// receiver or that argument has provenance, or its argument list names PlatformComparison +/// (the helper's case rule exists only to compare paths). A literal first argument classifies a +/// string ("ashlar.model.provider=", the device prefix @"\\.\") and cannot name a +/// root, so it counts only when it starts with .. on a receiver with GetRelativePath or +/// full-path provenance (the other common idiom), or it is an interpolated string whose holes have +/// provenance. Measured on 2026-09-30 at the base of this change: 126 production StartsWith +/// calls, of which exactly the 13 in the ten known copies matched, and no other; the one-step, +/// parameter, FileInfo/DirectoryInfo, interpolation and PlatformComparison rules +/// added no match there, nor across every .cs file in the repository including test projects +/// (3,149 tracked files, 227 calls). +/// +/// What it cannot see, stated so nobody mistakes green for proof. Provenance is by +/// identifier NAME within one file: not by type, not by scope, and not across files or through a +/// method's return value. So these are NOT counted, and the known-miss controls below pin each +/// StartsWith-shaped one at zero, so a scan that learns to see one must flip its control in +/// the same change: +/// +/// a helper whose operands get no provenance in its own file — its callers live in another +/// file, or it compares values that came back from a method call — such as +/// static bool Under(string a, string b) => a.StartsWith(b, StringComparison.Ordinal) +/// (unless it compares with PlatformComparison); +/// FullName of a FileInfo/DirectoryInfo the file receives rather than +/// constructs (a parameter, an enumeration item); +/// a separator-aware prefix whose root has no provenance in the file +/// (candidate.StartsWith(root + "/") with root from a method call): textually it is +/// the relative-allowlist idiom (rel.StartsWith(allowed + "/"), PathAllowlist's +/// prefixes, TouchSetNarrowing), which the scan deliberately ignores because it compares +/// repo-relative strings against configured prefixes, not a path against a root; +/// anything that is not StartsWith: IndexOf(root) == 0, +/// string.Compare(..., root.Length), Uri.IsBaseOf, a span SequenceEqual. +/// +/// In the other direction the name-based provenance over-approximates (a same-named +/// identifier elsewhere in the file inherits it), which can only ADD a hit and so fails loudly. The +/// scan is prevention, not proof: PathContainmentTests runs the adversarial tables against the +/// sites themselves, and that is what caught a separator-less helper this scan once missed. +/// +/// What is production. Every .cs under , minus build +/// output, agent scratch space, nested checkouts and TEST PROJECTS — a directory whose +/// .csproj references Microsoft.NET.Test.Sdk, the identity +/// TestOwnershipConventionTests uses. Not a directory whose name contains "Tests": that rule +/// pruned the production use case Testing/UseCases/RunTests. A test asserting where its own +/// temp file lives is not a security decision. +/// +/// Hermetic: pure file reads. +/// +[Trait("Category", "Certification")] +public sealed class PathContainmentConventionTests +{ + /// The one sanctioned containment check. Excluded from the ratchet; required to be recognised. + private const string HelperPath = "src/Ashlar.Abstractions/Paths/PathContainment.cs"; + + /// The marker TestOwnershipConventionTests identifies a test project by. + private const string TestSdkMarker = "Microsoft.NET.Test.Sdk"; + + private static readonly string[] ProductionRoots = ["src", "application", "applications", "commercial", "products", "tools"]; + + /// + /// The copies that remain, with the exact number of checks in each, on 2026-09-30. This list is + /// expected to SHRINK: convert a copy to PathContainment and lower (or delete) its pin in + /// the same change. Both remaining copies are already separator-aware; neither is the escape. + /// + private static readonly Dictionary Remaining = new(StringComparer.Ordinal) + { + // Volume-mount validation on the remote execution surface: separator-aware, root-inclusive, + // case-insensitive on Windows only. Left because converting it makes a src/ change span + // application/, which layer-boundary refuses without a [coordinated-integration] PR; it + // belongs in an application/ follow-up. + ["application/src/Ashlar.API/Endpoints/AshlarEndpoints.cs"] = 2, + + // sandbox.writable vs sandbox.root: separator-aware, root-inclusive, ordinal. Left because + // Ashlar.Manifest references no project at all, and PathContainment lives in + // Ashlar.Abstractions; converting it needs a new ProjectReference, which is its own decision. + ["src/Ashlar.Manifest/ProjectVerifier.cs"] = 1, + }; + + /// + /// Non-vacuity floors. Measured on 2026-09-30 with this change applied: 2,103 production .cs + /// files scanned and 117 StartsWith calls examined. Set far enough below to survive + /// ordinary deletions; a scan that silently stops reading the tree falls through them. + /// + private const int ScannedFilesFloor = 1000; + + private const int ExaminedCallsFloor = 60; + + [Fact] + public void No_production_file_hand_rolls_more_containment_checks_than_its_pin() + { + var scan = Scan(RepoPathResolver.FindRepoRoot()); + + var over = scan.Hits + .Where(kv => kv.Key != HelperPath) + .Select(kv => (File: kv.Key, Found: kv.Value, Pin: Remaining.TryGetValue(kv.Key, out var pin) ? pin : 0)) + .Where(x => x.Found.Count > x.Pin) + .OrderBy(x => x.File, StringComparer.Ordinal) + .Select(x => $"{x.File}: {x.Found.Count} found, pinned at {x.Pin} ({string.Join("; ", x.Found)})") + .ToList(); + + over.Should().BeEmpty( + "a hand-rolled containment check is how OutputPathSandboxed came to admit a sibling of its " + + "root. Call Ashlar.Abstractions.Paths.PathContainment.IsWithin (or IsStrictlyWithin) " + + "instead. If this is not a containment check, make that visible to the scan (no path " + + "provenance on its operands) rather than pinning it. Over the pin: {0}", + string.Join(" | ", over)); + } + + [Fact] + public void No_pin_is_higher_than_the_checks_its_file_still_has() + { + var scan = Scan(RepoPathResolver.FindRepoRoot()); + + var stale = Remaining + .Select(kv => (File: kv.Key, Pin: kv.Value, Found: scan.Hits.TryGetValue(kv.Key, out var hits) ? hits.Count : 0)) + .Where(x => x.Found < x.Pin) + .OrderBy(x => x.File, StringComparer.Ordinal) + .Select(x => $"{x.File}: pinned at {x.Pin}, {x.Found} found") + .ToList(); + + stale.Should().BeEmpty( + "a pin above what its file still contains overstates the remaining copies and leaves room " + + "for a new one to arrive unnoticed. Lower the pin, or delete the row at zero. Stale: {0}", + string.Join(" | ", stale)); + } + + [Fact] + public void The_scan_reads_the_tree_and_recognises_the_helper_itself() + { + var root = RepoPathResolver.FindRepoRoot(); + var scan = Scan(root); + + scan.FilesScanned.Should().BeGreaterThanOrEqualTo(ScannedFilesFloor, + "the scan must actually walk the production tree; fewer files means a root or a pruning " + + "rule broke and every assertion above is passing on nothing"); + scan.CallsExamined.Should().BeGreaterThanOrEqualTo(ExaminedCallsFloor, + "the scan must actually see StartsWith calls to classify"); + + File.Exists(Path.Combine(root, HelperPath.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue( + "{0} is the one sanctioned containment check", HelperPath); + scan.Hits.Should().ContainKey(HelperPath, + "the helper's own separator-aware comparison is the canonical shape; if the scan cannot " + + "recognise it on a real file, it cannot recognise a copy of it either"); + } + + /// + /// Pruning is by test-project identity, not by name. The production use case under + /// Testing/UseCases/RunTests has "Tests" in its directory name and was once skipped; a real + /// test project must still be skipped. + /// + [Fact] + public void Production_code_in_a_directory_named_like_tests_is_scanned_and_a_test_project_is_not() + { + var root = RepoPathResolver.FindRepoRoot(); + var scanned = Scan(root).Files; + + bool Scanned(string prefix) => scanned.Any(f => f.StartsWith(prefix, StringComparison.Ordinal)); + + Scanned("src/Ashlar.Core.Application/Testing/UseCases/RunTests/").Should().BeTrue( + "RunTests is production code in Ashlar.Core.Application, not a test project"); + Scanned("products/").Should().BeTrue("products/*/src ships C#"); + Scanned("tools/").Should().BeTrue("tools/ ships C#"); + Scanned("src/Ashlar.Tests.Infrastructure/").Should().BeFalse( + "a project that references {0} is a test project", TestSdkMarker); + Scanned("products/tests/").Should().BeFalse("products/tests holds a test project"); + } + + public static TheoryData ControlNames() + { + var data = new TheoryData(); + foreach (var name in Controls.Keys.OrderBy(k => k, StringComparer.Ordinal)) + data.Add(name); + return data; + } + + /// + /// Each control is a real shape from this repository (named where it came from) or a spelling a + /// careless edit would produce. Positive controls must count; negative controls must not; a + /// known miss must not either, until the scan learns to see it and the control is flipped. + /// + [Theory] + [MemberData(nameof(ControlNames))] + public void The_detector_classifies_each_control(string name) + { + var (source, expected) = Controls[name]; + + var hits = Scanner.Find(source, out var calls); + + calls.Should().BeGreaterThan(0, "a control with no StartsWith call exercises nothing"); + hits.Should().HaveCount(expected, "control '{0}' ({1})", name, string.Join("; ", hits)); + } + + private static readonly Dictionary Controls = new(StringComparer.Ordinal) + { + // ── must count ────────────────────────────────────────────────────────────────────────── + ["positive: OutputPathSandboxed as it was (no separator)"] = (""" + var full = Path.GetFullPath(output); + var baseDir = Path.GetFullPath(root); + if (!full.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase)) + return false; + """, 1), + ["positive: separator spelled in the argument (ToolSandbox as it was)"] = (""" + return candidate.StartsWith(normalisedRoot + Path.DirectorySeparatorChar, PathComparison) + || candidate.StartsWith(normalisedRoot + Path.AltDirectorySeparatorChar, PathComparison); + """, 2), + ["positive: prefix built by a multi-line ternary (ProjectVerifier)"] = (""" + var rootWithSep = resolvedRoot.EndsWith(Path.DirectorySeparatorChar) + ? resolvedRoot + : resolvedRoot + Path.DirectorySeparatorChar; + if (!resolved.Equals(resolvedRoot, StringComparison.Ordinal) + && !resolved.StartsWith(rootWithSep, StringComparison.Ordinal)) + { + } + """, 1), + ["positive: fully qualified GetFullPath as the receiver"] = (""" + if (System.IO.Path.GetFullPath(p).StartsWith(root, StringComparison.Ordinal)) { } + """, 1), + ["positive: span receiver"] = (""" + var full = Path.GetFullPath(p); + if (full.AsSpan().StartsWith(root.AsSpan(), StringComparison.Ordinal)) { } + """, 1), + ["positive: a relative path climbing out"] = (""" + var rel = Path.GetRelativePath(root, full); + if (rel.StartsWith("..", StringComparison.Ordinal)) return false; + """, 1), + ["positive: a quote char literal does not open a string that hides the check"] = (""" + if (c == '"' && full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)) { } + """, 1), + ["positive: a separator-less helper fed full paths by a caller in its own file (ToolSandbox, reintroduced)"] = (""" + candidate = Path.GetFullPath(Path.Combine(root, relativePath)); + if (!Inside(candidate, root)) return false; + private static bool Inside(string here, string there) => here.StartsWith(there, StringComparison.Ordinal); + """, 1), + ["positive: a block-bodied helper bound by named arguments"] = (""" + var full = Path.GetFullPath(p); + if (!Contains(outer: baseDir, inner: full)) return false; + private static bool Contains(string inner, string outer) + { + return inner.StartsWith(outer, StringComparison.OrdinalIgnoreCase); + } + """, 1), + ["positive: a helper comparing with the helper's own PlatformComparison"] = (""" + private static bool Under(string a, string b) => a.StartsWith(b, PathContainment.PlatformComparison); + """, 1), + ["positive: a separator prefix one assignment away"] = (""" + var rootFull = Path.GetFullPath(r); + var prefix = rootFull + "/"; + if (candidate.StartsWith(prefix, StringComparison.Ordinal)) { } + """, 1), + ["positive: a backslash char prefix one assignment away"] = (""" + var rootFull = Path.GetFullPath(r); + var prefix = rootFull + '\\'; + if (candidate.StartsWith(prefix, StringComparison.Ordinal)) { } + """, 1), + ["positive: a Path.Combine receiver one assignment away"] = (""" + var rootFull = Path.GetFullPath(r); + var combined = Path.Combine(rootFull, rel); + if (combined.StartsWith(allowed, StringComparison.Ordinal)) { } + """, 1), + ["positive: an alias of a full path, trimmed"] = (""" + var rootFull = Path.GetFullPath(r); + var trimmed = rootFull.TrimEnd('/'); + if (candidate.StartsWith(trimmed, StringComparison.Ordinal)) { } + """, 1), + ["positive: FileInfo and DirectoryInfo FullName"] = (""" + if (new FileInfo(p).FullName.StartsWith(new DirectoryInfo(r).FullName, StringComparison.Ordinal)) { } + """, 1), + ["positive: a DirectoryInfo held in a local"] = (""" + var rootInfo = new DirectoryInfo(r); + if (file.FullName.StartsWith(rootInfo.FullName, StringComparison.Ordinal)) { } + """, 1), + ["positive: an interpolated prefix embedding a full path"] = (""" + var rootFull = Path.GetFullPath(r); + if (candidate.StartsWith($"{rootFull}/", StringComparison.Ordinal)) { } + """, 1), + ["positive: an interpolated prefix spelling the separator"] = (""" + if (candidate.StartsWith($"{root}{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) { } + """, 1), + ["positive: an interpolated prefix one assignment away"] = (""" + var rootFull = Path.GetFullPath(r); + var prefix = $"{rootFull}/"; + if (candidate.StartsWith(prefix, StringComparison.Ordinal)) { } + """, 1), + + // ── must not count ────────────────────────────────────────────────────────────────────── + ["negative: literal prefix on a config line (MeaiBackedModel)"] = (""" + if (trimmed.StartsWith("ashlar.model.provider=", StringComparison.OrdinalIgnoreCase)) { } + """, 0), + ["negative: literal device prefix on a full path (SafePackageRead)"] = (""" + var full = Path.GetFullPath(path); + if (full.StartsWith(@"\\.\", StringComparison.Ordinal)) return false; + var win = full.StartsWith(@"\\?\", StringComparison.Ordinal) ? full : full; + """, 0), + ["negative: char literal on a full path"] = (""" + var full = Path.GetFullPath(p); + if (full.StartsWith('/')) { } + """, 0), + ["negative: relative allowlist prefix (PathAllowlist)"] = (""" + var rel = raw.TrimStart('/'); + if (!_allowedPrefixes.Any(a => rel.StartsWith(a, StringComparison.OrdinalIgnoreCase))) return false; + """, 0), + ["negative: a substring of a full path is relative again (MediatedWritePath.IsUnderAllowlist)"] = (""" + var fullPath = Path.GetFullPath(target); + var normalizedRel = fullPath.Substring(rootWithSep.Length).Replace('\\', '/'); + if (normalizedRel.StartsWith(e + "/", StringComparison.OrdinalIgnoreCase)) { } + """, 0), + ["negative: namespace prefix (TouchSetNarrowing)"] = (""" + if (ns.Equals(p, StringComparison.Ordinal) || ns.StartsWith(p + ".", StringComparison.Ordinal)) { } + """, 0), + ["negative: a type's FullName against a namespace"] = (""" + if (type.FullName.StartsWith(ns + ".", StringComparison.Ordinal)) { } + """, 0), + ["negative: URL path prefix (AshlarApiKeyAuthMiddleware)"] = (""" + return value.StartsWith(A2APathPrefix, StringComparison.OrdinalIgnoreCase); + """, 0), + ["negative: an interpolated prefix with no path in it"] = (""" + if (line.StartsWith($"{prefix}:", StringComparison.Ordinal)) { } + """, 0), + ["negative: an interpolated message quoting a full path is not a prefix"] = (""" + var full = Path.GetFullPath(p); + var message = $"escapes '{full}'"; + if (message.StartsWith(header, StringComparison.Ordinal)) { } + """, 0), + ["negative: a helper whose callers in its file pass no path"] = (""" + if (Has(name, header)) { } + private static bool Has(string a, string b) => a.StartsWith(b, StringComparison.Ordinal); + """, 0), + ["negative: a check that is commented out"] = (""" + // if (!full.StartsWith(root + Path.DirectorySeparatorChar)) return false; + /* candidate.StartsWith(root + Path.DirectorySeparatorChar) */ + if (name.StartsWith(prefix, StringComparison.Ordinal)) { } + """, 0), + ["negative: a check inside a string literal (a code template)"] = (""" + var template = "if (!full.StartsWith(root + Path.DirectorySeparatorChar)) return;"; + if (template.StartsWith(header, StringComparison.Ordinal)) { } + """, 0), + ["negative: a dot-dot literal on a string with no path provenance"] = (""" + if (version.StartsWith("..", StringComparison.Ordinal)) { } + """, 0), + ["negative: a check inside a verbatim string with doubled quotes"] = (""" + var doc = @"say ""x"" then full.StartsWith(root + Path.DirectorySeparatorChar)"; + if (doc.StartsWith(header, StringComparison.Ordinal)) { } + """, 0), + ["negative: a check inside a raw string literal (MockScaffoldingResponder templates)"] = ("""" + var t = """ + if (!full.StartsWith(root + Path.DirectorySeparatorChar)) return; + """; + if (t.StartsWith(header, StringComparison.Ordinal)) { } + """", 0), + + // ── known misses: real containment checks the scan cannot see (see the type remarks) ─── + // Flip one to 1 in the same change that teaches the scan to see it. + ["known miss: a helper whose callers live in another file"] = (""" + internal static bool Under(string candidate, string root) => candidate.StartsWith(root, StringComparison.Ordinal); + """, 0), + ["known miss: a root that comes back from a method call"] = (""" + var root = ResolveRoot(snapshot); + if (!candidate.StartsWith(root + "/", StringComparison.Ordinal)) return false; + """, 0), + ["known miss: FullName of a FileInfo the method receives"] = (""" + private static bool Under(FileInfo file, DirectoryInfo dir) => file.FullName.StartsWith(dir.FullName, StringComparison.Ordinal); + """, 0), + }; + + private sealed record ScanResult(Dictionary> Hits, List Files, int CallsExamined) + { + public int FilesScanned => Files.Count; + } + + private static ScanResult Scan(string root) + { + var hits = new Dictionary>(StringComparer.Ordinal); + var files = new List(); + var calls = 0; + foreach (var top in ProductionRoots) + { + var dir = Path.Combine(root, top); + if (Directory.Exists(dir) && !IsPruned(dir)) + Collect(root, dir, hits, files, ref calls); + } + + return new ScanResult(hits, files, calls); + } + + private static void Collect(string root, string directory, Dictionary> hits, List files, ref int calls) + { + foreach (var file in Directory.EnumerateFiles(directory, "*.cs")) + { + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + files.Add(relative); + var found = Scanner.Find(File.ReadAllText(file), out var examined); + calls += examined; + if (found.Count > 0) + hits[relative] = found; + } + + foreach (var child in Directory.EnumerateDirectories(directory)) + { + if (!IsPruned(child)) + Collect(root, child, hits, files, ref calls); + } + } + + /// Build output, agent scratch space, any nested checkout, and a test project (by its csproj). + private static bool IsPruned(string directory) + { + var name = Path.GetFileName(directory); + if (name is "bin" or "obj" or ".claude") + return true; + var git = Path.Combine(directory, ".git"); + if (File.Exists(git) || Directory.Exists(git)) + return true; + return Directory.EnumerateFiles(directory, "*.csproj") + .Any(project => File.ReadAllText(project).Contains(TestSdkMarker, StringComparison.Ordinal)); + } + + /// + /// The classifier. Text-level on purpose (no Roslyn in the required check): blank comments and + /// literal contents, work out which identifiers have full-path or relative-path provenance, then + /// classify each .StartsWith( by its receiver and arguments. + /// + private static class Scanner + { + private static readonly Regex Assignment = new(@"\b([A-Za-z_][A-Za-z0-9_]*)\s*=(?![=>])", RegexOptions.CultureInvariant); + + private static readonly Regex FullPathMark = new( + @"GetFullPath\s*\(|\bDirectorySeparatorChar\b|\bAltDirectorySeparatorChar\b|\bnew\s+(?:System\.IO\.)?(?:FileInfo|DirectoryInfo)\s*\(", + RegexOptions.CultureInvariant); + + private static readonly Regex RelativePathMark = new(@"GetRelativePath\s*\(", RegexOptions.CultureInvariant); + private static readonly Regex PathBuilder = new(@"\bPath\s*\.\s*(?:Combine|Join)\s*\(", RegexOptions.CultureInvariant); + private static readonly Regex Alias = new(@"^\s*[A-Za-z_][A-Za-z0-9_]*\s*(?:\.\s*Trim(?:End)?\s*\([^()]*\)\s*)?$", RegexOptions.CultureInvariant); + private static readonly Regex Concatenation = new(@"\+\s*", RegexOptions.CultureInvariant); + private static readonly Regex Hole = new(@"\{([^{}]*)\}", RegexOptions.CultureInvariant); + private static readonly Regex NameBeforeParen = new(@"\b([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^<>()]*>)?\s*\(", RegexOptions.CultureInvariant); + private static readonly Regex NamedArgument = new(@"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:(?!:)", RegexOptions.CultureInvariant); + private static readonly Regex PassedOut = new(@"^\s*out\b", RegexOptions.CultureInvariant); + private static readonly Regex PassedIn = new(@"^\s*(?:ref|in)\s+", RegexOptions.CultureInvariant); + private static readonly Regex PlatformComparison = new(@"\bPlatformComparison\b", RegexOptions.CultureInvariant); + private static readonly Regex Identifier = new(@"[A-Za-z_][A-Za-z0-9_]*", RegexOptions.CultureInvariant); + private static readonly Regex StartsWithCall = new(@"\.\s*StartsWith\s*\(", RegexOptions.CultureInvariant); + + /// A path separator spelled as a C# literal: "/", "\\", '/', '\\', @"\". + private static readonly HashSet SeparatorLiterals = new(StringComparer.Ordinal) + { + "\"/\"", "\"\\\\\"", "'/'", "'\\\\'", "@\"\\\"", + }; + + /// Words that precede '(' without naming a method. + private static readonly HashSet NotAMethod = new(StringComparer.Ordinal) + { + "if", "while", "for", "foreach", "switch", "catch", "using", "lock", "return", "nameof", "typeof", "sizeof", + "default", "new", "when", "fixed", "checked", "unchecked", "base", "this", "stackalloc", "await", "throw", + }; + + /// Words before a name that make name(...) an expression, not a declaration. + private static readonly HashSet ExpressionKeywords = new(StringComparer.Ordinal) + { + "return", "new", "await", "throw", "else", "in", "is", "as", "yield", "case", "when", "and", "or", "not", "out", "ref", + }; + + public static List Find(string source, out int calls) + { + var literals = new Dictionary(); + var code = Clean(source, literals); + var (fullIds, relativeIds) = Provenance(code, literals); + + var hits = new List(); + calls = 0; + foreach (Match call in StartsWithCall.Matches(code)) + { + calls++; + var open = call.Index + call.Length - 1; + var receiver = Receiver(code, call.Index); + var (argumentStart, argument) = Argument(code, open); + var receiverIds = Identifiers(receiver); + var lead = argumentStart + (argument.Length - argument.TrimStart().Length); + + bool counts; + if (literals.TryGetValue(lead, out var literal)) + { + var body = literal.TrimStart('@', '$').TrimStart('"'); + var holes = Holes(literal); + counts = (body.StartsWith("..", StringComparison.Ordinal) + && (RelativePathMark.IsMatch(receiver) || FullPathMark.IsMatch(receiver) + || receiverIds.Overlaps(relativeIds) || receiverIds.Overlaps(fullIds))) + || (holes.Length > 0 && (FullPathMark.IsMatch(holes) || Identifiers(holes).Overlaps(fullIds))); + } + else + { + counts = FullPathMark.IsMatch(receiver) || FullPathMark.IsMatch(argument) + || receiverIds.Overlaps(fullIds) || Identifiers(argument).Overlaps(fullIds) + || PlatformComparison.IsMatch(code[open..ClosingParen(code, open)]); + } + + if (counts) + { + var line = 1 + code.Take(call.Index).Count(ch => ch == '\n'); + hits.Add($"line {line}: {receiver.Trim()}.StartsWith({argument.Trim()}, ...)"); + } + } + + return hits; + } + + private static HashSet Identifiers(string text) + => Identifier.Matches(text).Select(m => m.Value).ToHashSet(StringComparer.Ordinal); + + /// + /// Identifiers with full-path or relative-path provenance: seeded by assignments and bound + /// arguments that spell a mark, then grown along path-building assignments and parameter + /// bindings until nothing changes. + /// + private static (HashSet Full, HashSet Relative) Provenance(string code, Dictionary literals) + { + var full = new HashSet(StringComparer.Ordinal); + var relative = new HashSet(StringComparer.Ordinal); + var edges = new List<(string Target, HashSet Sources)>(); + + foreach (Match m in Assignment.Matches(code)) + { + var start = m.Index + m.Length; + var rhs = RightHandSide(code, start); + var expression = Expression(code, literals, start, start + rhs.Length); + var name = m.Groups[1].Value; + if (FullPathMark.IsMatch(expression)) + full.Add(name); + if (RelativePathMark.IsMatch(expression)) + relative.Add(name); + if (BuildsAPath(rhs, start, literals)) + edges.Add((name, Identifiers(expression))); + } + + foreach (var (parameter, start, end) in Bindings(code)) + { + var expression = Expression(code, literals, start, end); + if (FullPathMark.IsMatch(expression)) + full.Add(parameter); + edges.Add((parameter, Identifiers(expression))); + } + + bool grew; + do + { + grew = false; + foreach (var (target, sources) in edges) + { + if (!full.Contains(target) && sources.Overlaps(full)) + { + full.Add(target); + grew = true; + } + } + } + while (grew); + + return (full, relative); + } + + /// The cleaned code of a span plus the holes of every interpolated literal that starts in it. + private static string Expression(string code, Dictionary literals, int start, int end) + { + var holes = LiteralsIn(literals, start, end).Select(Holes).Where(h => h.Length > 0).ToList(); + return holes.Count == 0 ? code[start..end] : code[start..end] + " " + string.Join(" ", holes); + } + + private static IEnumerable LiteralsIn(Dictionary literals, int start, int end) + { + for (var k = start; k < end; k++) + { + if (literals.TryGetValue(k, out var literal)) + yield return literal; + } + } + + /// + /// True when an assignment's right-hand side builds a path from what it names: + /// Path.Combine/Path.Join, a plain (optionally trimmed) alias, a concatenated + /// separator literal, or an interpolated string with a separator in its text. + /// + private static bool BuildsAPath(string rhs, int start, Dictionary literals) + { + if (PathBuilder.IsMatch(rhs) || Alias.IsMatch(rhs)) + return true; + + foreach (Match plus in Concatenation.Matches(rhs)) + { + if (literals.TryGetValue(start + plus.Index + plus.Length, out var literal) && SeparatorLiterals.Contains(literal)) + return true; + } + + return LiteralsIn(literals, start, start + rhs.Length) + .Any(l => Holes(l).Length > 0 && Hole.Replace(l, " ").IndexOfAny(['/', '\\']) >= 0); + } + + /// The expressions inside an interpolated literal's holes, or empty for any other literal. + private static string Holes(string literal) + { + var quote = literal.IndexOf('"', StringComparison.Ordinal); + if (quote <= 0 || literal.IndexOf('$', 0, quote) < 0) + return string.Empty; + return string.Join(" ", Hole.Matches(literal).Select(m => m.Groups[1].Value)); + } + + /// + /// (parameter, argument span) for every call in this file to a method declared in this file: + /// each argument binds to the parameter at its position, or of its name. An out + /// argument flows the other way and binds nothing. + /// + private static List<(string Parameter, int Start, int End)> Bindings(string code) + { + var sites = NameBeforeParen.Matches(code) + .Select(m => (Name: m.Groups[1].Value, At: m.Index, Open: m.Index + m.Length - 1)) + .ToList(); + + var declared = new Dictionary>(StringComparer.Ordinal); + foreach (var (name, at, open) in sites) + { + if (!IsDeclaration(code, name, at, open, out var close)) + continue; + var parameters = SplitTopLevel(code, open + 1, close, angleBrackets: true) + .Select(p => ParameterName(code[p.Start..p.End])) + .ToArray(); + if (!declared.TryGetValue(name, out var overloads)) + declared[name] = overloads = []; + overloads.Add((at, parameters)); + } + + var bindings = new List<(string Parameter, int Start, int End)>(); + foreach (var (name, at, open) in sites) + { + if (!declared.TryGetValue(name, out var overloads) || overloads.Any(o => o.At == at)) + continue; + var arguments = SplitTopLevel(code, open + 1, ClosingParen(code, open), angleBrackets: false); + foreach (var (_, parameters) in overloads) + { + if (arguments.Count > parameters.Length) + continue; + for (var i = 0; i < arguments.Count; i++) + { + var (start, end) = arguments[i]; + if (PassedOut.IsMatch(code[start..end])) + continue; + var parameter = parameters[i]; + var named = NamedArgument.Match(code[start..end]); + if (named.Success) + { + parameter = parameters.Contains(named.Groups[1].Value) ? named.Groups[1].Value : string.Empty; + start += named.Length; + } + + var passed = PassedIn.Match(code[start..end]); + if (passed.Success) + start += passed.Length; + if (parameter.Length > 0) + bindings.Add((parameter, start, end)); + } + } + } + + return bindings; + } + + /// + /// A declaration is Type Name(params) followed by a body ({), an expression body + /// (=>) or a constraint (where); a call is anything else. + /// + private static bool IsDeclaration(string code, string name, int at, int open, out int close) + { + close = ClosingParen(code, open); + if (NotAMethod.Contains(name)) + return false; + + var j = at - 1; + while (j >= 0 && char.IsWhiteSpace(code[j])) + j--; + if (j < 0 || !(char.IsLetterOrDigit(code[j]) || code[j] is '_' or '>' or ']' or '?')) + return false; + if (char.IsLetterOrDigit(code[j]) || code[j] == '_') + { + var end = j + 1; + while (j >= 0 && (char.IsLetterOrDigit(code[j]) || code[j] == '_')) + j--; + if (ExpressionKeywords.Contains(code[(j + 1)..end])) + return false; + } + + var k = close + 1; + while (k < code.Length && char.IsWhiteSpace(code[k])) + k++; + return k < code.Length + && (code[k] == '{' + || string.CompareOrdinal(code, k, "=>", 0, 2) == 0 + || (string.CompareOrdinal(code, k, "where", 0, 5) == 0 + && (k + 5 >= code.Length || !(char.IsLetterOrDigit(code[k + 5]) || code[k + 5] == '_')))); + } + + /// The name a parameter declares: its last identifier before any default value. + private static string ParameterName(string declaration) + { + var equals = declaration.IndexOf('=', StringComparison.Ordinal); + var head = equals < 0 ? declaration : declaration[..equals]; + var names = Identifier.Matches(head); + return names.Count == 0 ? string.Empty : names[^1].Value; + } + + /// Comma-separated spans at bracket depth zero; angle brackets count only in a declaration. + private static List<(int Start, int End)> SplitTopLevel(string code, int start, int end, bool angleBrackets) + { + var parts = new List<(int Start, int End)>(); + if (end <= start || string.IsNullOrWhiteSpace(code[start..end])) + return parts; + + var depth = 0; + var from = start; + for (var k = start; k < end; k++) + { + var ch = code[k]; + if (ch is '(' or '[' or '{' || (angleBrackets && ch == '<')) + { + depth++; + } + else if (ch is ')' or ']' or '}' || (angleBrackets && ch == '>')) + { + depth--; + } + else if (ch == ',' && depth == 0) + { + parts.Add((from, k)); + from = k + 1; + } + } + + parts.Add((from, end)); + return parts; + } + + /// The index of the ')' closing the '(' at , or the end of the code. + private static int ClosingParen(string code, int open) + { + var depth = 0; + for (var k = open; k < code.Length; k++) + { + if (code[k] == '(') + { + depth++; + } + else if (code[k] == ')') + { + depth--; + if (depth == 0) + return k; + } + } + + return code.Length; + } + + /// Up to the end of the expression: a top-level ';' or ',', or a bracket closing one this expression never opened. + private static string RightHandSide(string code, int start) + { + var depth = 0; + var k = start; + for (; k < code.Length; k++) + { + var ch = code[k]; + if (ch is '(' or '[' or '{') + { + depth++; + } + else if (ch is ')' or ']' or '}') + { + if (depth == 0) + break; + depth--; + } + else if (ch is ';' or ',' && depth == 0) + { + break; + } + } + + return code[start..k]; + } + + /// The member-access chain before '.StartsWith', including balanced (...) and [...]. + private static string Receiver(string code, int dot) + { + var j = dot - 1; + while (j >= 0 && char.IsWhiteSpace(code[j])) + j--; + var end = j + 1; + var depth = 0; + for (; j >= 0; j--) + { + var ch = code[j]; + if (ch is ')' or ']') + { + depth++; + } + else if (ch is '(' or '[') + { + if (depth == 0) + break; + depth--; + } + else if (depth == 0 && !(char.IsLetterOrDigit(ch) || ch is '_' or '.' or '?' or '!')) + { + break; + } + } + + return code[(j + 1)..end]; + } + + /// The first argument: from after '(' to a top-level ',' or the closing ')'. + private static (int Start, string Text) Argument(string code, int openParen) + { + var depth = 0; + var k = openParen + 1; + for (; k < code.Length; k++) + { + var ch = code[k]; + if (ch is '(' or '[' or '{') + { + depth++; + } + else if (ch is ')' or ']' or '}') + { + if (depth == 0) + break; + depth--; + } + else if (ch == ',' && depth == 0) + { + break; + } + } + + return (openParen + 1, code[(openParen + 1)..k]); + } + + /// + /// Blanks comments and the CONTENTS of string and char literals (delimiters and newlines kept, + /// so offsets and line numbers survive), and records each literal's original text by the + /// offset of its first character. + /// + private static string Clean(string text, Dictionary literals) + { + var buffer = text.ToCharArray(); + var n = text.Length; + var i = 0; + while (i < n) + { + var c = text[i]; + var next = i + 1 < n ? text[i + 1] : '\0'; + + if (c == '/' && next == '/') + { + var end = text.IndexOf('\n', i); + end = end < 0 ? n : end; + Blank(buffer, i, end); + i = end; + continue; + } + + if (c == '/' && next == '*') + { + var close = text.IndexOf("*/", i + 2, StringComparison.Ordinal); + var end = close < 0 ? n : close + 2; + Blank(buffer, i, end); + i = end; + continue; + } + + if (c == '\'') + { + var k = i + 1; + while (k < n && text[k] != '\'' && text[k] != '\n') + k += text[k] == '\\' ? 2 : 1; + var end = Math.Min(k + 1, n); + literals[i] = text[i..end]; + Blank(buffer, i + 1, end - 1); + i = end; + continue; + } + + if (c == '"' || (c is '@' or '$' && next is '"' or '@' or '$')) + { + var j = i; + var verbatim = false; + while (j < n && text[j] is '@' or '$') + { + verbatim |= text[j] == '@'; + j++; + } + + if (j >= n || text[j] != '"') + { + i++; + continue; + } + + var q = j; + while (q < n && text[q] == '"') + q++; + var quotes = q - j; + if (quotes >= 3) + { + // Raw string literal: closed by the same run of quotes. + var close = text.IndexOf(new string('"', quotes), q, StringComparison.Ordinal); + var rawEnd = close < 0 ? n : close + quotes; + literals[i] = text[i..rawEnd]; + Blank(buffer, q, close < 0 ? n : close); + i = rawEnd; + continue; + } + + var k = j + 1; + while (k < n) + { + var ch = text[k]; + if (verbatim) + { + if (ch == '"' && k + 1 < n && text[k + 1] == '"') + { + k += 2; + continue; + } + + if (ch == '"') + break; + } + else + { + if (ch == '\\') + { + k += 2; + continue; + } + + if (ch is '"' or '\n') + break; + } + + k++; + } + + var stringEnd = Math.Min(k + 1, n); + literals[i] = text[i..stringEnd]; + Blank(buffer, j + 1, stringEnd - 1); + i = stringEnd; + continue; + } + + i++; + } + + return new string(buffer); + } + + private static void Blank(char[] buffer, int from, int to) + { + for (var k = from; k < to && k < buffer.Length; k++) + { + if (buffer[k] != '\n') + buffer[k] = ' '; + } + } + } +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/PathContainmentTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/PathContainmentTests.cs new file mode 100644 index 000000000..8d6416fb5 --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/PathContainmentTests.cs @@ -0,0 +1,429 @@ +using System.Text.Json; +using FluentAssertions; +using Ashlar.Abstractions; +using Ashlar.Abstractions.Paths; +using Ashlar.Policies; +using Ashlar.Policies.Dev; +using Ashlar.Tools.Dev; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// +/// Two adversarial tables of containment spellings: one run against every call site that admits an +/// ABSOLUTE candidate path, and one run against the sites that resolve a model-supplied RELATIVE +/// path against a sandbox root. +/// +/// The escape this pins. OutputPathSandboxed compared +/// Path.GetFullPath(output).StartsWith(Path.GetFullPath(OutputRoot), OrdinalIgnoreCase) with no +/// directory separator after the root, so with OutputRoot = .../app/out the policy approved +/// .../app/out-evil/x, .../app/outx and .../app/out.dll: every sibling of the +/// output directory whose name extends out. It is live: AgentExecutorAdapter composes it +/// with AllowAllPolicy and sets OutputRoot to <cwd>/out, and +/// assembly.decompile creates and writes its output directory. On Linux the same +/// ignore-case comparison also admitted a case variant of the root, which there names a different +/// directory; PathAllowlist's absolute-path leg had that half of the defect too. +/// +/// Rows of the absolute table are lexical: nothing is created on disk, so the table is hermetic +/// and runs identically everywhere, except the one row whose answer depends on the platform's default +/// case rule, which is computed here from the OS, never from the code under test. The relative table +/// builds a real sandbox with real siblings beside it, because the tools it runs against read and +/// list what they resolve. +/// +[Trait("Category", "Unit")] +public sealed class PathContainmentTests +{ + private static readonly char Sep = Path.DirectorySeparatorChar; + + /// A root that never exists: every row is decided lexically. + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ashlar-containment-table", "app", "out"); + + private static readonly string Parent = Path.GetDirectoryName(Root)!; + + private static bool CaseFoldingPlatform => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); + + /// One spelling: the root the site is configured with, the candidate, and the verdicts. + public sealed record Case(string Name, string RootSpelling, string Candidate, bool Inside, bool StrictlyInside); + + /// + /// The shared table. counts the root itself as inside (what every + /// converted call site means); does not (what + /// MediatedWritePath means). + /// + public static IReadOnlyList Table { get; } = + [ + new("root-itself", Root, Root, true, false), + new("root-with-trailing-separator-on-candidate", Root, Root + Sep, true, false), + new("root-with-trailing-separator-on-root", Root + Sep, Root, true, false), + new("child", Root, Path.Combine(Root, "child.txt"), true, true), + new("child-of-root-with-trailing-separator", Root + Sep, Path.Combine(Root, "child.txt"), true, true), + new("dotdot-that-stays-inside", Root, Path.Combine(Root, "a", "b", "..", "c.txt"), true, true), + new("doubled-separator", Root, Root + Sep + Sep + "child.txt", true, true), + new("root-spelled-with-dotdot", Path.Combine(Root, "sub", ".."), Path.Combine(Root, "child.txt"), true, true), + new("dotdot-to-parent", Root, Path.Combine(Root, ".."), false, false), + new("dotdot-to-sibling", Root, Path.Combine(Root, "..", "out-evil", "x"), false, false), + new("deep-dotdot-escape", Root, Path.Combine(Root, "..", "..", "..", "..", "elsewhere"), false, false), + new("sibling-prefix-directory", Root, Path.Combine(Parent, "out-evil", "x"), false, false), + new("sibling-prefix-file", Root, Path.Combine(Parent, "out.dll"), false, false), + new("sibling-prefix-bare", Root, Path.Combine(Parent, "outx"), false, false), + // Windows: parent\evil. Elsewhere '\' is an ordinary file-name character, so this names the + // file "out\..\evil" beside the root: a sibling prefix again. Outside on every platform. + new("backslash-dotdot-suffix", Root, Root + "\\..\\evil", false, false), + // Windows does not normalise a \\?\ path, so '..' survives GetFullPath and a prefix test would + // read this as inside; the helper refuses a surviving dot segment. Elsewhere the prefix is + // ordinary characters and this is the sibling "out\..\evil" again. Outside everywhere; only a + // Windows run exercises the dot-segment leg. + new("extended-length-dotdot", @"\\?\" + Root, @"\\?\" + Root + @"\..\evil", false, false), + new("case-variant-of-root", Root, Root.ToUpperInvariant() + Sep + "x", CaseFoldingPlatform, CaseFoldingPlatform), + new("unrelated-absolute", Root, Path.Combine(Path.GetPathRoot(Root)!, "elsewhere", "x"), false, false), + new("filesystem-root-as-root", Path.GetPathRoot(Root)!, Path.Combine(Root, "child.txt"), true, true), + new("empty-candidate", Root, "", false, false), + ]; + + public static TheoryData CaseNames() + { + var data = new TheoryData(); + foreach (var c in Table) + data.Add(c.Name); + return data; + } + + private static Case Row(string name) => Table.Single(c => c.Name == name); + + /// The arrange step must have produced the spelling it claims, or a row proves nothing. + [Fact] + public void The_table_is_what_it_says() + { + Table.Select(c => c.Name).Should().OnlyHaveUniqueItems(); + Path.IsPathRooted(Root).Should().BeTrue(); + Directory.Exists(Root).Should().BeFalse("the table is lexical; a real directory would hide a filesystem dependence"); + + var caseVariant = Row("case-variant-of-root").Candidate; + caseVariant.Should().NotStartWith(Root, "the case-variant row must differ from the root ordinally or it tests nothing"); + caseVariant.StartsWith(Root, StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + + Row("sibling-prefix-directory").Candidate.Should().StartWith(Root, "a sibling-prefix row must share the root's characters, which is the whole attack"); + Row("sibling-prefix-file").Candidate.Should().StartWith(Root); + Row("sibling-prefix-bare").Candidate.Should().StartWith(Root); + Row("backslash-dotdot-suffix").Candidate.Should().StartWith(Root); + } + + [Theory] + [MemberData(nameof(CaseNames))] + public void IsWithin_answers_the_table(string name) + { + var c = Row(name); + + PathContainment.IsWithin(c.Candidate, c.RootSpelling).Should().Be(c.Inside, + "{0}: '{1}' within '{2}'", c.Name, c.Candidate, c.RootSpelling); + } + + [Theory] + [MemberData(nameof(CaseNames))] + public void IsStrictlyWithin_answers_the_table(string name) + { + var c = Row(name); + + PathContainment.IsStrictlyWithin(c.Candidate, c.RootSpelling).Should().Be(c.StrictlyInside, + "{0}: '{1}' strictly within '{2}'", c.Name, c.Candidate, c.RootSpelling); + } + + [Fact] + public void The_platform_comparison_is_the_default_file_system_case_rule() + { + PathContainment.PlatformComparison.Should().Be( + CaseFoldingPlatform ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + + [Fact] + public void An_explicit_comparison_overrides_the_platform_rule_on_every_platform() + { + var caseVariant = Row("case-variant-of-root").Candidate; + + PathContainment.IsWithin(caseVariant, Root, StringComparison.Ordinal).Should().BeFalse(); + PathContainment.IsStrictlyWithin(caseVariant, Root, StringComparison.Ordinal).Should().BeFalse(); + PathContainment.IsWithin(caseVariant, Root, StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + PathContainment.IsStrictlyWithin(caseVariant, Root, StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + + // The separator rule does not depend on the comparison. + var sibling = Row("sibling-prefix-directory").Candidate; + PathContainment.IsWithin(sibling, Root, StringComparison.Ordinal).Should().BeFalse(); + PathContainment.IsWithin(sibling, Root, StringComparison.OrdinalIgnoreCase).Should().BeFalse(); + } + + [Theory] + [InlineData(StringComparison.CurrentCulture)] + [InlineData(StringComparison.CurrentCultureIgnoreCase)] + [InlineData(StringComparison.InvariantCulture)] + [InlineData(StringComparison.InvariantCultureIgnoreCase)] + public void A_culture_aware_comparison_is_refused(StringComparison comparison) + { + var child = Path.Combine(Root, "child.txt"); + + var within = () => PathContainment.IsWithin(child, Root, comparison); + var strictly = () => PathContainment.IsStrictlyWithin(child, Root, comparison); + + within.Should().Throw().Which.ParamName.Should().Be("comparison"); + strictly.Should().Throw().Which.ParamName.Should().Be("comparison"); + } + + public static TheoryData Unresolvable() => new() + { + { null, Root }, + { "", Root }, + { " ", Root }, + { Path.Combine(Root, "child.txt"), null }, + { Path.Combine(Root, "child.txt"), "" }, + { Path.Combine(Root, "child.txt"), " " }, + { Path.Combine(Root, "bad\0name"), Root }, + { Path.Combine(Root, "child.txt"), Root + "\0" }, + }; + + /// A path that cannot be resolved is never inside anything, and asking never throws. + [Theory] + [MemberData(nameof(Unresolvable))] + public void An_unresolvable_argument_is_never_within(string? candidate, string? root) + { + PathContainment.IsWithin(candidate, root).Should().BeFalse(); + PathContainment.IsStrictlyWithin(candidate, root).Should().BeFalse(); + PathContainment.IsWithin(candidate, root, StringComparison.Ordinal).Should().BeFalse(); + PathContainment.IsStrictlyWithin(candidate, root, StringComparison.OrdinalIgnoreCase).Should().BeFalse(); + } + + /// + /// Pins what the helper deliberately does NOT do: it is lexical, so a link inside the root that + /// points outside it reads as inside. A writer must probe for links itself, as + /// MediatedWritePath does after its containment leg. + /// + [Fact] + public void It_is_lexical_and_does_not_follow_a_link_out_of_the_root() + { + var stem = Path.Combine(Path.GetTempPath(), "ashlar-containment-link-" + Guid.NewGuid().ToString("N")); + var root = Path.Combine(stem, "root"); + var outside = Path.Combine(stem, "outside"); + Directory.CreateDirectory(root); + Directory.CreateDirectory(outside); + try + { + var link = Path.Combine(root, "link"); + try + { + Directory.CreateSymbolicLink(link, outside); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; // platform refuses unprivileged symlinks — nothing to assert + } + + var throughLink = Path.Combine(link, "x.txt"); + File.WriteAllText(throughLink, "lands outside"); + File.Exists(Path.Combine(outside, "x.txt")).Should().BeTrue("the arrange step must really route a write outside the root"); + + PathContainment.IsWithin(throughLink, root).Should().BeTrue( + "containment is lexical by contract; this fact exists so nobody mistakes it for a link check"); + } + finally + { + try { Directory.Delete(stem, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + } + + [Theory] + [MemberData(nameof(CaseNames))] + public void OutputPathSandboxed_admits_exactly_the_table(string name) + { + var c = Row(name); + var snapshot = new WorldSnapshot(0, new Dictionary { ["OutputRoot"] = c.RootSpelling }); + var call = new ToolCall("assembly.decompile", JsonSerializer.SerializeToElement(new Dictionary { ["output"] = c.Candidate })); + + var approved = new OutputPathSandboxed().Approve(call, snapshot, out var reason); + + approved.Should().Be(c.Inside, "{0}: output '{1}' against OutputRoot '{2}' ({3})", c.Name, c.Candidate, c.RootSpelling, reason); + } + + [Theory] + [MemberData(nameof(CaseNames))] + public void PathAllowlist_absolute_leg_admits_exactly_the_table(string name) + { + var c = Row(name); + var snapshot = new WorldSnapshot(0, new Dictionary { ["SandboxRoot"] = c.RootSpelling }); + var call = new ToolCall("repo.fs.write", JsonSerializer.SerializeToElement(new Dictionary { ["path"] = c.Candidate })); + + var approved = new PathAllowlist().Approve(call, snapshot, out var reason); + + approved.Should().Be(c.Inside, "{0}: path '{1}' against SandboxRoot '{2}' ({3})", c.Name, c.Candidate, c.RootSpelling, reason); + } + + /// One model-supplied path, relative to the sandbox root, and whether it lands inside. + public sealed record RelativeCase(string Name, string Candidate, bool Inside); + + /// + /// The RELATIVE table, for the sites that resolve a model-supplied path against a sandbox root: + /// ToolSandbox.TryResolvePath (the read resolver) and, through it, repo.fs.read and + /// repo.fs.list; ToolSandbox.TryResolveWritePath for the escapes. The root is + /// <stem>/repo, and every sibling row names something that really exists beside it + /// with the root's name as a prefix (repo-evil/, repox, repo.dll). + /// + /// This table exists because the absolute one could not see it: a separator-less helper + /// put back into ToolSandbox (here.StartsWith(there, PathContainment.PlatformComparison)) + /// left every existing suite green, and the read and list tools then read and listed the + /// siblings. The write resolver stays shut even then, because MediatedWritePath checks + /// containment again; its rows pin that it does. + /// + public static IReadOnlyList RelativeTable { get; } = + [ + new("child", "src/x.cs", true), + new("root-itself", ".", true), + new("dotdot-back-inside", "../repo/src/x.cs", true), + new("parent", "..", false), + new("sibling-prefix-directory", "../repo-evil", false), + new("sibling-prefix-directory-child", "../repo-evil/x.cs", false), + new("sibling-prefix-bare", "../repox", false), + new("sibling-prefix-file", "../repo.dll", false), + new("dotdot-to-sibling-through-a-child", "src/../../repo-evil/x.cs", false), + new("case-variant-of-root", "../REPO/src/x.cs", CaseFoldingPlatform), + ]; + + public static TheoryData RelativeCaseNames() + { + var data = new TheoryData(); + foreach (var c in RelativeTable) + data.Add(c.Name); + return data; + } + + /// The rows that must be refused on this platform (the case variant only where case is significant). + public static TheoryData RelativeEscapeNames() + { + var data = new TheoryData(); + foreach (var c in RelativeTable.Where(c => !c.Inside)) + data.Add(c.Name); + return data; + } + + private static RelativeCase RelativeRow(string name) => RelativeTable.Single(c => c.Name == name); + + /// A sandbox at <stem>/repo with a file inside it and three siblings that extend its name. + private sealed class RelativeSandbox : IDisposable + { + public const string Secret = "SECRET-beside-the-sandbox"; + + private readonly string _stem; + + public RelativeSandbox() + { + _stem = Path.Combine(Path.GetTempPath(), "ashlar-containment-rel-" + Guid.NewGuid().ToString("N")); + Root = Path.Combine(_stem, "repo"); + Directory.CreateDirectory(Path.Combine(Root, "src")); + File.WriteAllText(Path.Combine(Root, "src", "x.cs"), "inside"); + Directory.CreateDirectory(Path.Combine(_stem, "repo-evil")); + File.WriteAllText(Path.Combine(_stem, "repo-evil", "x.cs"), Secret); + File.WriteAllText(Path.Combine(_stem, "repox"), Secret); + File.WriteAllText(Path.Combine(_stem, "repo.dll"), Secret); + } + + public string Root { get; } + + public WorldSnapshot Snapshot => WorldSnapshot.ForRepo(Root); + + public void Dispose() + { + try { Directory.Delete(_stem, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + } + + private static ToolCall PathCall(string tool, string path) + => new(tool, JsonSerializer.SerializeToElement(new Dictionary { ["path"] = path })); + + /// Every sibling row must really share the root's characters and really exist, or a refusal proves nothing. + [Fact] + public void The_relative_table_is_what_it_says() + { + using var sandbox = new RelativeSandbox(); + + RelativeTable.Select(c => c.Name).Should().OnlyHaveUniqueItems(); + File.Exists(Path.Combine(sandbox.Root, "src", "x.cs")).Should().BeTrue(); + + var siblings = RelativeTable.Where(c => c.Name.Contains("sibling", StringComparison.Ordinal)).ToList(); + siblings.Should().HaveCount(5); + foreach (var c in siblings) + { + var resolved = Path.GetFullPath(Path.Combine(sandbox.Root, c.Candidate)); + resolved.Should().StartWith(sandbox.Root, + "{0}: a sibling-prefix row must share the root's characters, which is the whole attack", c.Name); + (File.Exists(resolved) || Directory.Exists(resolved)).Should().BeTrue( + "{0}: '{1}' must really exist, or refusing it proves nothing", c.Name, resolved); + } + } + + [Theory] + [MemberData(nameof(RelativeCaseNames))] + public void ToolSandbox_read_resolver_admits_exactly_the_relative_table(string name) + { + var c = RelativeRow(name); + using var sandbox = new RelativeSandbox(); + + var admitted = ToolSandbox.TryResolvePath(sandbox.Snapshot, c.Candidate, out var full, out var reason); + + admitted.Should().Be(c.Inside, "{0}: '{1}' against RepoRoot '{2}' resolved to '{3}' ({4})", + c.Name, c.Candidate, sandbox.Root, full, reason); + } + + [Theory] + [MemberData(nameof(RelativeEscapeNames))] + public void ToolSandbox_write_resolver_refuses_every_escape_in_the_relative_table(string name) + { + var c = RelativeRow(name); + using var sandbox = new RelativeSandbox(); + + var admitted = ToolSandbox.TryResolveWritePath(sandbox.Snapshot, c.Candidate, out var full, out var reason); + + admitted.Should().BeFalse("{0}: '{1}' against RepoRoot '{2}' resolved to '{3}'", c.Name, c.Candidate, sandbox.Root, full); + reason.Should().StartWith("REJECTED"); + } + + /// The control for the row above: the write resolver still admits an ordinary file. + [Fact] + public void ToolSandbox_write_resolver_admits_an_ordinary_child() + { + using var sandbox = new RelativeSandbox(); + + ToolSandbox.TryResolveWritePath(sandbox.Snapshot, "src/x.cs", out var full, out var reason).Should().BeTrue(reason); + full.Should().Be(Path.Combine(sandbox.Root, "src", "x.cs")); + } + + [Theory] + [MemberData(nameof(RelativeCaseNames))] + public async Task RepoFsReadTool_reads_nothing_beside_the_sandbox(string name) + { + var c = RelativeRow(name); + using var sandbox = new RelativeSandbox(); + + var result = await new RepoFsReadTool().InvokeAsync(PathCall("repo.fs.read", c.Candidate), sandbox.Snapshot, CancellationToken.None); + + var payload = JsonSerializer.Serialize(result.Payload); + result.Delta.Log.Any(l => l.Contains("REJECTED", StringComparison.Ordinal)).Should().Be(!c.Inside, + "{0}: repo.fs.read of '{1}' ({2})", c.Name, c.Candidate, payload); + payload.Should().NotContain(RelativeSandbox.Secret, "{0}: nothing beside the sandbox may be read", c.Name); + } + + [Theory] + [MemberData(nameof(RelativeCaseNames))] + public async Task RepoFsListTool_lists_nothing_beside_the_sandbox(string name) + { + var c = RelativeRow(name); + using var sandbox = new RelativeSandbox(); + + var result = await new RepoFsListTool().InvokeAsync(PathCall("repo.fs.list", c.Candidate), sandbox.Snapshot, CancellationToken.None); + + var payload = JsonSerializer.Serialize(result.Payload); + result.Delta.Log.Any(l => l.Contains("REJECTED", StringComparison.Ordinal)).Should().Be(!c.Inside, + "{0}: repo.fs.list of '{1}' ({2})", c.Name, c.Candidate, payload); + using var document = JsonDocument.Parse(payload); + var listed = document.RootElement.TryGetProperty("entries", out var entries) + ? entries.EnumerateArray().Select(e => e.GetProperty("path").GetString() ?? "").ToList() + : new List(); + listed.Should().NotContain(p => p.StartsWith("..", StringComparison.Ordinal), + "{0}: nothing beside the sandbox may be listed ({1})", c.Name, payload); + } +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Hosting/MeaiVectorDataRagAdapterTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Hosting/MeaiVectorDataRagAdapterTests.cs new file mode 100644 index 000000000..9b76a6f3a --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Hosting/MeaiVectorDataRagAdapterTests.cs @@ -0,0 +1,76 @@ +using Ashlar.AI.Pipeline.Embeddings; +using Ashlar.AI.Pipeline.Governance; +using Ashlar.AI.Pipeline.Rag; +using Ashlar.Hosting.Meai; +using FluentAssertions; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Hosting; + +/// +/// MeaiVectorDataRagAdapter is the IRAGService the shipped hosts resolve (phase 13b overrides the +/// legacy one unconditionally), so it is what rag search and rag index actually run. +/// It used to substitute a tier for a missing one in both directions, and both substitutions were +/// open: an omitted search clearance became TopSecret (the whole corpus, audited as if cleared), +/// and an omitted index label became Public (served to everyone). +/// +public sealed class MeaiVectorDataRagAdapterTests +{ + private const string Text = "shared incident playbook guidance"; + + [Fact] + public async Task Search_without_a_clearance_is_the_floor_and_is_audited_as_omitted_not_as_TopSecret() + { + var (adapter, auditor) = Create(); + await adapter.IndexAsync("pub", Text, "Public"); + await adapter.IndexAsync("sec", Text, "Secret"); + + var hits = await adapter.SearchAsync("incident playbook", 10, 0.0, null); + + hits.Select(h => h.Id).Should().BeEquivalentTo(new[] { "pub" }); + var decisions = auditor.Records.Last(r => r.TargetKey == "rag:search").PolicyDecisions; + decisions.Should().Contain("caller_tier=Public").And.Contain("caller_tier_basis=omitted"); + decisions.Should().NotContain("caller_tier=TopSecret"); + + // POSITIVE CONTROL: the Secret chunk is reachable with a clearance, so the empty answer + // above is the floor and not a chunk that failed to index. + (await adapter.SearchAsync("incident playbook", 10, 0.0, "Secret")).Select(h => h.Id) + .Should().BeEquivalentTo(new[] { "pub", "sec" }); + } + + [Fact] + public async Task Index_without_a_label_is_unmarked_and_served_only_to_the_top_clearance() + { + var (adapter, _) = Create(); + await adapter.IndexAsync("/kb/notes.md", Text, sensitivityLevelName: null); + + (await adapter.SearchAsync("incident playbook", 10, 0.0, null)).Should().BeEmpty(); + (await adapter.SearchAsync("incident playbook", 10, 0.0, "Public")).Should().BeEmpty(); + (await adapter.SearchAsync("incident playbook", 10, 0.0, "Secret")).Should().BeEmpty(); + + var top = await adapter.SearchAsync("incident playbook", 10, 0.0, "TopSecret"); + top.Should().ContainSingle().Which.SensitivityLevelName.Should().BeNull( + "IRAGService reports an unmarked document as null, and the adapter invents no label for it"); + } + + [Fact] + public async Task Index_over_an_existing_id_at_a_lower_label_is_refused() + { + var (adapter, _) = Create(); + await adapter.IndexAsync("/kb/plan.md", Text, "Secret"); + + var downgrade = async () => await adapter.IndexAsync("/kb/plan.md", Text, "Public"); + + await downgrade.Should().ThrowAsync().WithMessage("*Refusing to re-index*"); + (await adapter.SearchAsync("incident playbook", 10, 0.0, "Secret")) + .Should().ContainSingle().Which.SensitivityLevelName.Should().Be("Secret"); + } + + private static (MeaiVectorDataRagAdapter Adapter, InMemoryChatInvocationAuditor Auditor) Create() + { + var auditor = new InMemoryChatInvocationAuditor(); + var collection = new InProcessVectorStore().GetCollection(VectorDataRagService.DefaultCollectionName); + var rag = new VectorDataRagService(collection, new TokenHashEmbeddingGenerator(), auditor); + return (new MeaiVectorDataRagAdapter(rag), auditor); + } +} diff --git a/src/Ashlar.Tests.Kernel/OutputPathSandboxedTests.cs b/src/Ashlar.Tests.Kernel/OutputPathSandboxedTests.cs index a993acf87..3a7c83e80 100644 --- a/src/Ashlar.Tests.Kernel/OutputPathSandboxedTests.cs +++ b/src/Ashlar.Tests.Kernel/OutputPathSandboxedTests.cs @@ -81,6 +81,22 @@ public void Approve_rejects_path_outside_sandbox() } } + [Fact] + public void Approve_rejects_a_sibling_whose_name_extends_the_sandbox_root() + { + // The check compared without a directory separator after the root, so OutputRoot .../out + // admitted .../out-evil, .../outx and .../out.dll: every sibling sharing the root's name. + var p = new OutputPathSandboxed(); + var root = Path.Combine(Path.GetTempPath(), "ashlar-sibling-" + Guid.NewGuid().ToString("N"), "out"); + var sibling = root + "-evil" + Path.DirectorySeparatorChar + "x.cs"; + sibling.Should().StartWith(root, "the attack is a path that shares the root's characters"); + var snap = new WorldSnapshot(0, new Dictionary { ["OutputRoot"] = root }); + var call = new ToolCall("t", JsonDocument.Parse(JsonSerializer.Serialize(new Dictionary { ["output"] = sibling })).RootElement); + + p.Approve(call, snap, out var reason).Should().BeFalse(); + reason.Should().Contain("escapes sandbox"); + } + [Fact] public void Approve_rejects_null_output_value_as_invalid_path() { @@ -91,6 +107,28 @@ public void Approve_rejects_null_output_value_as_invalid_path() reason.Should().Be("Invalid output path"); } + [Theory] + [InlineData("""{"output":5}""")] + [InlineData("""{"output":true}""")] + [InlineData("""{"output":["/tmp/x"]}""")] + [InlineData("""{"output":{"path":"/tmp/x"}}""")] + public void Approve_refuses_a_non_string_output_instead_of_throwing(string arguments) + { + // GetString() on a non-string JSON value throws. It used to run outside the try, so the + // exception left Approve (and PolicyEngine.Approve) instead of a refusal coming back. + var p = new OutputPathSandboxed(); + var snap = new WorldSnapshot(0, new Dictionary { ["OutputRoot"] = Path.GetTempPath() }); + var call = new ToolCall("t", JsonDocument.Parse(arguments).RootElement); + + var approved = true; + var reason = ""; + var approve = () => { approved = p.Approve(call, snap, out reason); }; + + approve.Should().NotThrow(); + approved.Should().BeFalse(); + reason.Should().Be("Invalid output path"); + } + [Fact] public void Approve_rejects_invalid_path_with_invalid_chars() { diff --git a/src/Ashlar.Tools.Dev/RepoFsListTool.cs b/src/Ashlar.Tools.Dev/RepoFsListTool.cs index e6351c9d8..32b1d4321 100644 --- a/src/Ashlar.Tools.Dev/RepoFsListTool.cs +++ b/src/Ashlar.Tools.Dev/RepoFsListTool.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Ashlar.Abstractions; +using Ashlar.Abstractions.Paths; using Ashlar.Tools.Dev.Deltas; namespace Ashlar.Tools.Dev; @@ -172,10 +173,7 @@ private static bool TryResolveRelative(string root, string path, out string full var rootFull = Path.GetFullPath(root); var combined = Path.GetFullPath(Path.Combine(rootFull, normalized)); - var rootWithSep = rootFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - if (!combined.StartsWith(rootWithSep, StringComparison.OrdinalIgnoreCase) && - !string.Equals(combined, rootFull, StringComparison.OrdinalIgnoreCase)) + if (!PathContainment.IsWithin(combined, rootFull)) { error = "resolved path escapes root"; return false; diff --git a/src/Ashlar.Tools.Dev/RepoFsReadTool.cs b/src/Ashlar.Tools.Dev/RepoFsReadTool.cs index 58a70ab06..d5d62c9ec 100644 --- a/src/Ashlar.Tools.Dev/RepoFsReadTool.cs +++ b/src/Ashlar.Tools.Dev/RepoFsReadTool.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Ashlar.Abstractions; +using Ashlar.Abstractions.Paths; using Ashlar.Tools.Dev.Deltas; namespace Ashlar.Tools.Dev; @@ -108,10 +109,7 @@ internal static bool TryResolveRelative(string root, string path, out string ful var rootFull = Path.GetFullPath(root); var combined = Path.GetFullPath(Path.Combine(rootFull, normalized)); - var rootWithSep = rootFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - if (!combined.StartsWith(rootWithSep, StringComparison.OrdinalIgnoreCase) && - !string.Equals(combined, rootFull, StringComparison.OrdinalIgnoreCase)) + if (!PathContainment.IsWithin(combined, rootFull)) { error = "resolved path escapes root"; return false; diff --git a/src/Ashlar.Tools.Dev/ToolSandbox.cs b/src/Ashlar.Tools.Dev/ToolSandbox.cs index 074812415..ced46aeb8 100644 --- a/src/Ashlar.Tools.Dev/ToolSandbox.cs +++ b/src/Ashlar.Tools.Dev/ToolSandbox.cs @@ -1,4 +1,5 @@ using Ashlar.Abstractions; +using Ashlar.Abstractions.Paths; using Ashlar.Core.Application.Paths; namespace Ashlar.Tools.Dev; @@ -129,7 +130,7 @@ public static bool TryResolvePath( // GetFullPath has already collapsed any "..", so this catches traversal without // needing to pattern-match on the raw string. - if (!IsWithin(candidate, root)) + if (!PathContainment.IsWithin(candidate, root)) { reason = $"REJECTED: path traversal outside the sandbox root not permitted: {relativePath}"; return false; @@ -200,21 +201,4 @@ public static bool TryResolveWritePath( return true; } - - private static bool IsWithin(string candidate, string root) - { - var normalisedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (string.Equals(candidate, normalisedRoot, PathComparison)) - { - return true; - } - - return candidate.StartsWith(normalisedRoot + Path.DirectorySeparatorChar, PathComparison) - || candidate.StartsWith(normalisedRoot + Path.AltDirectorySeparatorChar, PathComparison); - } - - private static StringComparison PathComparison => - OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; } diff --git a/tests/scripts/required-runner-pins.test.sh b/tests/scripts/required-runner-pins.test.sh new file mode 100644 index 000000000..76413972b --- /dev/null +++ b/tests/scripts/required-runner-pins.test.sh @@ -0,0 +1,507 @@ +#!/usr/bin/env bash +# Tests for scripts/ci/verify-required-runner-pins.py. +# +# WHY A TEST AND NOT JUST THE CHECK. A scanner that stops matching is indistinguishable from a +# repository with nothing to find: both print a clean line and exit 0. So each case below builds a +# throwaway tree holding the five required workflows, all pinned, and changes ONE thing the check must +# refuse - and a refusal counts only when it names its reason and the line, so a python traceback +# (also exit 1) can never pass for one. The first case proves the untouched fixture is accepted, so a +# refusal below is caused by the change and not by the fixture. +# +# The check has a strict mode (floor, required OS families) that only runs bare. To reach it on a +# fixture, a copy of the check is placed inside the fixture tree and run bare there: it locates the +# repository from its own path. +# +# Run: bash tests/scripts/required-runner-pins.test.sh +# Pure bash + python (PyYAML): no network, no dotnet, no container. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHECK="${ROOT}/scripts/ci/verify-required-runner-pins.py" + +# PROBE the interpreter, do not merely locate it. On Windows `command -v python3` finds an App +# Execution Alias stub that exists, is executable, and exits 49 - indistinguishable from a refusal to +# any assertion that only checks a non-zero exit. +PY_BIN="" +for candidate in python3 python py; do + if command -v "${candidate}" >/dev/null 2>&1 && "${candidate}" -c 'import yaml' >/dev/null 2>&1; then + PY_BIN="${candidate}" + break + fi +done +if [[ -z "${PY_BIN}" ]]; then + echo "FAIL - no working python with PyYAML on PATH; this file cannot test anything and will not pretend to." + exit 1 +fi +if [[ ! -f "${CHECK}" ]]; then + echo "FAIL - ${CHECK} does not exist; nothing here can be tested." + exit 1 +fi + +# Bump when you add an assertion; the check at the bottom says why. +EXPECTED_ASSERTIONS=50 + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS + 1)); echo " ok - $1"; } +bad() { FAIL=$((FAIL + 1)); echo " FAIL - $1"; echo " $2"; } + +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +W=".github/workflows" + +# A fresh tree with the five required workflows, every job pinned: 12 judged labels, like the real +# tree. Echoes its path. Called as $(tree), a subshell, so mktemp makes each tree its own. +tree() { + local d + d="$(mktemp -d "${TMP}/t.XXXXXX")" + mkdir -p "${d}/${W}" + cat > "${d}/${W}/cert-gate.yml" <<'YML' +name: Cert gate +on: + pull_request: +jobs: + cert-gate: + name: cert-gate + runs-on: ubuntu-24.04 + steps: + - run: echo cert +YML + cat > "${d}/${W}/build-gate.yml" <<'YML' +name: Build Gate +on: + pull_request: +jobs: + build-core: + name: build-core + runs-on: ubuntu-24.04 + steps: + - run: echo build +YML + cat > "${d}/${W}/shell-lint.yml" <<'YML' +name: Shell lint +on: + pull_request: +jobs: + shell-lint: + name: shell-lint + runs-on: ubuntu-24.04 + steps: + - run: echo lint +YML + cat > "${d}/${W}/docs-link-check.yml" <<'YML' +name: Docs Link Check +on: + pull_request: +jobs: + lychee: + name: lychee (README + docs) + runs-on: ubuntu-24.04 + steps: + - run: echo lychee +YML + cat > "${d}/${W}/full-platform-readiness-gate.yml" <<'YML' +name: Full Platform Readiness Gate +on: + pull_request: +jobs: + changes: + name: "Changes" + runs-on: ubuntu-24.04 + steps: + - run: echo changes + native-platform: + name: "${{ matrix.label }} lane" + needs: changes + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-24.04 + label: Linux + - os: macos-26 + label: macOS + - os: windows-2025-vs2026 + label: Windows + steps: + - run: echo native + container-platform: + runs-on: ubuntu-24.04 + steps: + - run: echo container + docker-cli-image: + runs-on: ubuntu-24.04 + steps: + - run: echo cli + docker-all-images: + runs-on: ubuntu-24.04 + steps: + - run: echo images + readiness-summary: + name: "Readiness summary" + runs-on: ubuntu-24.04 + needs: [changes, native-platform] + steps: + - run: echo summary +YML + printf '%s' "${d}" +} + +# Replace the first line matching $2 in fixture file $1 with $3 (literal strings, no regex), and FAIL +# the case loudly if nothing matched: a mutation that did not apply would make the case vacuous. +swap() { + "${PY_BIN}" - "$1" "$2" "$3" <<'PY' || { echo "swap: '$2' not found in $1" >&2; return 1; } +import sys +path, old, new = sys.argv[1], sys.argv[2], sys.argv[3] +text = open(path, encoding="utf-8").read() +if old not in text: + sys.exit(1) +open(path, "w", encoding="utf-8", newline="\n").write(text.replace(old, new, 1)) +PY +} + +# Echoes "|" of the check in fixture mode. +check() { + local out code + out="$("${PY_BIN}" "${CHECK}" --root "$1" 2>&1)"; code=$? + printf '%s|%s' "${code}" "${out}" +} + +# Echoes "|" of a copy of the check run BARE inside fixture $1 (strict mode). +check_strict() { + local out code + mkdir -p "$1/scripts/ci" + cp "${CHECK}" "$1/scripts/ci/verify-required-runner-pins.py" + out="$("${PY_BIN}" "$1/scripts/ci/verify-required-runner-pins.py" 2>&1)"; code=$? + printf '%s|%s' "${code}" "${out}" +} + +expect() { # label want_rc needle got + local label="$1" want="$2" needle="$3" got="$4" + local code="${got%%|*}" out="${got#*|}" + if [[ "${code}" == "${want}" ]] && grep -qF -- "${needle}" <<<"${out}"; then + ok "${label}" + else + bad "${label}" "wanted exit ${want} and '${needle}'; got exit ${code}: ${out}" + fi +} + +echo "== the fixture itself is clean, so every refusal below is caused by its one change ==" +expect "the untouched five-workflow fixture is accepted" 0 \ + "5 workflow(s), 10 job(s), 12 runner label(s): 12 pinned, 0 excepted, 0 refused; 0 problem(s)" \ + "$(check "$(tree)")" +expect "and accepted in strict mode too (floor and OS families met)" 0 "0 problem(s)" \ + "$(check_strict "$(tree)")" + +echo "== a floating label is refused wherever a required workflow's job names it ==" +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "runs-on: ubuntu-24.04" "runs-on: ubuntu-latest" +expect "ubuntu-latest on the required job itself is refused, with its line" 1 \ + "${W}/cert-gate.yml:7: job 'cert-gate' runs on 'ubuntu-latest', which is a floating -latest label" \ + "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" " changes: + name: \"Changes\" + runs-on: ubuntu-24.04" " changes: + name: \"Changes\" + runs-on: ubuntu-latest" +expect "ubuntu-latest on a job that only FEEDS the required one (readiness 'changes') is refused" 1 \ + "${W}/full-platform-readiness-gate.yml:7: job 'changes' runs on 'ubuntu-latest'" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" "- os: macos-26" "- os: macos-latest" +expect "macos-latest reached through runs-on: \${{ matrix.os }} is refused at the matrix value's line" 1 \ + "${W}/full-platform-readiness-gate.yml:19: job 'native-platform' runs on 'macos-latest'" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" "- os: windows-2025-vs2026" "- os: windows-latest" +expect "windows-latest in the matrix is refused" 1 \ + "job 'native-platform' runs on 'windows-latest', which is a floating -latest label" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" " strategy: + matrix: + include: + - os: ubuntu-24.04 + label: Linux" " strategy: + matrix: + os: [ubuntu-24.04, ubuntu-latest] + include: + - os: ubuntu-24.04 + label: Linux" +expect "a floating label in a matrix-level list (not include:) is refused" 1 \ + "${W}/full-platform-readiness-gate.yml:16: job 'native-platform' runs on 'ubuntu-latest'" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/build-gate.yml" "runs-on: ubuntu-24.04" "runs-on: Ubuntu-Latest" +expect "a -latest label in another case is refused (runner labels are case-insensitive)" 1 \ + "job 'build-core' runs on 'Ubuntu-Latest', which is a floating -latest label" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/shell-lint.yml" "runs-on: ubuntu-24.04" "runs-on: macos-latest-xlarge" +expect "a -latest larger-runner label (macos-latest-xlarge) is refused" 1 \ + "runs on 'macos-latest-xlarge', which is a floating -latest label" "$(check "${d}")" + +echo "== an unpinned label that does not say latest is refused too ==" +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" "- os: windows-2025-vs2026" "- os: windows-2025" +expect "windows-2025 (OS only; it moved from the VS2022 to the VS2026 image) is refused" 1 \ + "runs on 'windows-2025', which names the Windows Server release but not the Visual Studio image" \ + "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/docs-link-check.yml" "runs-on: ubuntu-24.04" "runs-on: self-hosted" +expect "an unknown label (self-hosted) is refused: unknown is not pinned" 1 \ + "runs on 'self-hosted', which is not a label this check knows to be pinned" "$(check "${d}")" + +# A pinned shape must match the WHOLE label. These two start like one and are not it: a larger-runner +# name, and the image name a "Set up job" step prints (`Image: macos-26-arm64`), which is no label. +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "runs-on: ubuntu-24.04" "runs-on: ubuntu-24.04-16core" +expect "a label that only starts like a pinned one (ubuntu-24.04-16core) is refused" 1 \ + "${W}/cert-gate.yml:7: job 'cert-gate' runs on 'ubuntu-24.04-16core', which is not a label this check knows to be pinned" \ + "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" "- os: macos-26" "- os: macos-26-arm64" +expect "the image name macos-26-arm64 used as a matrix os is refused (it is not a runner label)" 1 \ + "${W}/full-platform-readiness-gate.yml:19: job 'native-platform' runs on 'macos-26-arm64', which is not a label this check knows to be pinned" \ + "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/docs-link-check.yml" "runs-on: ubuntu-24.04" "runs-on: [self-hosted, linux]" +expect "a label list is judged label by label" 1 \ + "runs on 'linux', which is not a label this check knows to be pinned" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "runs-on: ubuntu-24.04" "runs-on: \${{ inputs.runner }}" +expect "an expression it cannot evaluate is refused, not skipped" 1 \ + "runs on '\${{ inputs.runner }}', which is an expression this check cannot evaluate statically" \ + "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "runs-on: ubuntu-24.04" "runs-on: \${{ matrix.os }}" +expect "a matrix reference on a job with no matrix is refused" 1 \ + "(the job has no strategy.matrix)" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" "runs-on: \${{ matrix.os }}" "runs-on: \${{ matrix.runner }}" +expect "a matrix key the matrix does not define is refused" 1 \ + "(the matrix defines no 'runner')" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" " strategy: + matrix: + include:" " strategy: + matrix: \${{ fromJSON(needs.changes.outputs.m) }} + unused: + include:" +expect "a matrix built from an expression is refused" 1 \ + "(strategy.matrix is an expression)" "$(check "${d}")" + +# The matrix-level os values are all pinned, so only the include: expression can make this red: it +# may add any runner at run time. +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" " include: + - os: ubuntu-24.04 + label: Linux + - os: macos-26 + label: macOS + - os: windows-2025-vs2026 + label: Windows +" " os: [ubuntu-24.04, macos-26, windows-2025-vs2026] + include: \${{ fromJSON(needs.changes.outputs.extra) }} +" +expect "an include: built from an expression, beside a pinned matrix-level os, is refused" 1 \ + "${W}/full-platform-readiness-gate.yml:17: job 'native-platform' runs on '\${{ ... }} (strategy.matrix.include is an expression)'" \ + "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/build-gate.yml" " runs-on: ubuntu-24.04 + steps: + - run: echo build" " uses: ./.github/workflows/reusable-build.yml" +expect "a job that calls a reusable workflow (runner not visible) is refused" 1 \ + "runs on ''" "$(check "${d}")" + +echo "== every runs-on shape is judged, none is skipped ==" +d="$(tree)"; swap "${d}/${W}/build-gate.yml" " runs-on: ubuntu-24.04" " runs-on: + group: big-runners + labels: [ubuntu-latest]" +expect "a runner-group mapping is judged by its labels: (ubuntu-latest there is refused)" 1 \ + "${W}/build-gate.yml:9: job 'build-core' runs on 'ubuntu-latest', which is a floating -latest label" \ + "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/build-gate.yml" " runs-on: ubuntu-24.04" " runs-on: + group: big-runners" +expect "a runner-group mapping with no labels: is refused (the group decides the image)" 1 \ + "job 'build-core' runs on ''" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/docs-link-check.yml" "runs-on: ubuntu-24.04" "runs-on: []" +expect "an empty runner list is refused" 1 \ + "${W}/docs-link-check.yml:7: job 'lychee' runs on ''" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/shell-lint.yml" " runs-on: ubuntu-24.04 +" "" +expect "a job with neither runs-on nor uses is refused at its key" 1 \ + "${W}/shell-lint.yml:5: job 'shell-lint' runs on ''" "$(check "${d}")" + +echo "== the only exception is a reasoned comment on the value's own line ==" +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "runs-on: ubuntu-24.04" \ + "runs-on: ubuntu-latest # runner-pin: allow trialling the next image on this branch only" +expect "a -latest label with a reasoned exception on its line is accepted and counted as excepted" 0 \ + "12 runner label(s): 11 pinned, 1 excepted, 0 refused; 0 problem(s)" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" "- os: macos-26" \ + "- os: macos-latest # runner-pin: allow the macOS lane tracks the newest image by decision" +expect "an exception on a matrix value's own line is accepted" 0 "1 excepted, 0 refused; 0 problem(s)" \ + "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "runs-on: ubuntu-24.04" "runs-on: ubuntu-latest # runner-pin: allow" +expect "an exception with no reason is refused" 1 \ + "its \`# runner-pin: allow\` comment gives no reason" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "runs-on: ubuntu-24.04" \ + "runs-on: ubuntu-24.04 # runner-pin: allow left over from before the pin" +expect "an exception on a label that is already pinned is refused as stale" 1 \ + "${W}/cert-gate.yml:7: job 'cert-gate': stale exception - 'ubuntu-24.04' is already pinned" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" "- os: macos-26" "- os: macos-latest" +swap "${d}/${W}/full-platform-readiness-gate.yml" "runs-on: \${{ matrix.os }}" \ + "runs-on: \${{ matrix.os }} # runner-pin: allow macOS floats" +got="$(check "${d}")" +expect "an exception on the runs-on line, not the matrix value's, is stray" 1 \ + "${W}/full-platform-readiness-gate.yml:13: stray \`# runner-pin: allow\` comment" "${got}" +expect "... and does not excuse the matrix value it was meant for" 1 \ + "job 'native-platform' runs on 'macos-latest'" "${got}" + +d="$(tree)"; swap "${d}/${W}/shell-lint.yml" " steps:" " # runner-pin: allow nothing in particular + steps:" +expect "a runner-pin comment on a line that holds no label is refused as stray" 1 \ + "${W}/shell-lint.yml:8: stray \`# runner-pin: allow\` comment" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "runs-on: ubuntu-24.04" "runs-on: ubuntu-latest # runner-pin: skip" +expect "a runner-pin comment in any other form is refused as malformed" 1 \ + "${W}/cert-gate.yml:7: malformed runner-pin comment" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" " strategy: + matrix: + include:" " strategy: + matrix: + os: [ubuntu-24.04, macos-latest] # runner-pin: allow the macOS entry floats on purpose + include:" +expect "one exception on a flow list covers the line; its pinned neighbour does not make it stale" 0 \ + "1 excepted, 0 refused; 0 problem(s)" "$(check "${d}")" + +# It must be a COMMENT. The same text inside a quoted value on the label's own line is data. +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" " - os: macos-26 + label: macOS" " - { os: macos-latest, label: \"macOS # runner-pin: allow not a comment at all\" }" +got="$(check "${d}")" +expect "exception text inside a quoted value on the label's line does not excuse it" 1 \ + "${W}/full-platform-readiness-gate.yml:19: job 'native-platform' runs on 'macos-latest', which is a floating -latest label" \ + "${got}" +expect "... and is refused as runner-pin text that is not a comment" 1 \ + "${W}/full-platform-readiness-gate.yml:19: \`runner-pin:\` text inside a YAML value" "${got}" + +# Control for the case above: a '#' inside an earlier quoted value does not hide the real comment. +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" " - os: macos-26 + label: macOS" " - { os: macos-latest, label: \"mac # os\" } # runner-pin: allow the macOS lane tracks the newest image by decision" +expect "a real exception comment after a quoted value that holds '#' is still honoured" 0 \ + "1 excepted, 0 refused; 0 problem(s)" "$(check "${d}")" + +# A block scalar's content is a value (a shell comment is not a YAML one); its header line is not. +d="$(tree)"; swap "${d}/${W}/shell-lint.yml" " - run: echo lint" " - run: | + echo lint # runner-pin: allow a shell comment in a run: script" +expect "runner-pin text inside a run: | script is refused as not a comment" 1 \ + "${W}/shell-lint.yml:10: \`runner-pin:\` text inside a YAML value" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/shell-lint.yml" " - run: echo lint" " - run: | # runner-pin: allow on a block header + echo lint" +expect "a runner-pin comment on a block scalar's header line is a real comment, refused as stray" 1 \ + "${W}/shell-lint.yml:9: stray \`# runner-pin: allow\` comment" "$(check "${d}")" + +echo "== scope: the five required workflows, and only those ==" +d="$(tree)"; cat > "${d}/${W}/some-advisory-gate.yml" <<'YML' +name: Advisory +on: + pull_request: +jobs: + advisory: + runs-on: ubuntu-latest + steps: + - run: echo advisory +YML +expect "a -latest label in a workflow outside the five is not this check's business" 0 \ + "5 workflow(s), 10 job(s), 12 runner label(s)" "$(check "${d}")" + +d="$(tree)"; rm "${d}/${W}/shell-lint.yml" +expect "a required workflow that is missing is refused, not skipped" 1 \ + "${W}/shell-lint.yml: missing. It carries the required check 'shell-lint'" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" 'name: "Readiness summary"' 'name: "Readiness roll-up"' +expect "a required workflow whose required job was renamed is refused" 1 \ + "no job is named 'Readiness summary'" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/docs-link-check.yml" "jobs:" "jobs: [" +expect "a required workflow that does not parse is refused" 1 \ + "${W}/docs-link-check.yml: does not parse as YAML" "$(check "${d}")" + +d="$(tree)"; swap "${d}/${W}/cert-gate.yml" "jobs: + cert-gate:" "jobs: {} +unused: + cert-gate:" +expect "a required workflow with no jobs is refused" 1 \ + "${W}/cert-gate.yml: no jobs found" "$(check "${d}")" + +EMPTY="$(mktemp -d "${TMP}/empty.XXXXXX")" +got="$(check "${EMPTY}")" +expect "a tree with no workflows at all refuses instead of reporting clean" 1 "0 workflow(s), 0 job(s)" "${got}" +n_missing="$(grep -c ": missing. It carries the required check" <<<"${got#*|}")" +if [[ "${got%%|*}" == "1" && "${n_missing}" == "5" ]]; then + ok "... and names every missing required workflow (all five)" +else + bad "... and names every missing required workflow (all five)" "named ${n_missing}: ${got}" +fi + +echo "== strict mode: the floor and the OS families ==" +d="$(tree)"; swap "${d}/${W}/full-platform-readiness-gate.yml" " - os: macos-26 + label: macOS + - os: windows-2025-vs2026 + label: Windows +" "" +got="$(check_strict "${d}")" +expect "strict: losing the macOS and Windows lanes from the scan is refused (macos)" 1 \ + "no macos runner was judged" "${got}" +expect "strict: ... (windows)" 1 "no windows runner was judged" "${got}" + +# Drop four single-label readiness jobs: 12 - 4 = 8 judged labels, under the floor of 10. +d="$(tree)"; "${PY_BIN}" - "${d}/${W}/full-platform-readiness-gate.yml" <<'PY' +import re, sys +p = sys.argv[1] +t = open(p, encoding="utf-8").read() +for job in ("container-platform", "docker-cli-image", "docker-all-images", "changes"): + t, n = re.subn(r"\n " + re.escape(job) + r":\n(?: .*\n| .*\n)+", "\n", t, count=1) + assert n == 1, job +open(p, "w", encoding="utf-8", newline="\n").write(t) +PY +expect "strict: fewer judged labels than the floor is refused" 1 \ + "judged 8 runner label(s), expected at least 10" "$(check_strict "${d}")" + +echo "== the real tree ==" +# Bare, as run-repo-gates runs it: the floor and the OS families are live here and nowhere else. The +# listing is the non-vacuity control - it must show a judged label from EACH of the five files and +# from the macOS and Windows lanes, or a clean summary line means the scan reached less than it claims. +got="$("${PY_BIN}" "${CHECK}" 2>&1)"; code=$? +if [[ "${code}" == "0" ]] && grep -q "0 problem(s)" <<<"${got}"; then + ok "every required job in this repository runs on a pinned label (${got##*: })" +else + bad "every required job in this repository runs on a pinned label" "exit ${code}: ${got}" +fi +missing="" +for f in cert-gate.yml build-gate.yml shell-lint.yml docs-link-check.yml full-platform-readiness-gate.yml; do + grep -q "^required-runner-pins: ${W}/${f}:[0-9]* " <<<"${got}" || missing+=" ${f}" +done +grep -q " -> macos-[^ ]* \[" <<<"${got}" || missing+=" " +grep -q " -> windows-[^ ]* \[" <<<"${got}" || missing+=" " +if [[ -z "${missing}" ]]; then + ok "the real-tree scan judged a label in all five required workflows and in the macOS and Windows lanes" +else + bad "the real-tree scan reached everything it must" "not reached:${missing}; output: ${got}" +fi + +echo +echo "passed: ${PASS} failed: ${FAIL}" + +RAN=$((PASS + FAIL)) +if [[ "${RAN}" -ne "${EXPECTED_ASSERTIONS}" ]]; then + echo "FAIL - ran ${RAN} assertions, expected ${EXPECTED_ASSERTIONS}." + echo " Either this file stopped early or assertions were added without bumping" + echo " EXPECTED_ASSERTIONS. A partial run is not a pass." + exit 1 +fi + +[[ "${FAIL}" -eq 0 ]] || exit 1 diff --git a/tests/uat/tier9.sh b/tests/uat/tier9.sh index 63d2381d5..476844fbb 100644 --- a/tests/uat/tier9.sh +++ b/tests/uat/tier9.sh @@ -49,7 +49,12 @@ say "9.2 the experimental surface is not in the stable PROMISE" # "the class name" / "Declared class name" inside [Experimental] files must not count as a # type called `name` (that matched constructor parameters in Shipped.txt after the v0.1.2 # PublicAPI promotion). +# Product code only. A TEST file can legitimately contain the text "[Experimental(" - a convention test +# that polices the attribute does - and declare fixture classes beside it, and harvesting those names +# reported the real, [Obsolete] AshlarSdkBuilder as an experimental type leaked into Ashlar.Sdk's +# Shipped.txt. Test projects are every directory whose name contains "Tests". EXP_TYPES=$(grep -rl "\[Experimental(" --include=*.cs src/ 2>/dev/null \ + | grep -vE '(^|/)[^/]*Tests[^/]*/' \ | xargs -r grep -hE '^[[:space:]]*(public|internal|private|protected)?[[:space:]]*(sealed|abstract|static|partial|readonly|ref)*[[:space:]]*(class|record|interface|enum|struct)[[:space:]]+[A-Z][A-Za-z0-9_]*' 2>/dev/null \ | grep -oE '(class|record|interface|enum|struct)[[:space:]]+[A-Z][A-Za-z0-9_]*' \ | awk '{print $2}' | sort -u) @@ -61,7 +66,12 @@ for t in $EXP_TYPES; do fi done EXP_COUNT=$(printf '%s\n' "$EXP_TYPES" | grep -c .) -if [ -z "$LEAKED" ]; then +# 82 [Experimental] types were declared in product code on 2026-10-01. A scan that finds far fewer has +# lost its lens (a moved tree, a broken pattern), and "none leaked" from an empty list proves nothing. +EXP_FLOOR=60 +if [ "$EXP_COUNT" -lt "$EXP_FLOOR" ]; then + result 9 experimental-not-promised FAIL "the scan found only $EXP_COUNT [Experimental] types (floor $EXP_FLOOR), so its PASS would mean nothing" +elif [ -z "$LEAKED" ]; then result 9 experimental-not-promised PASS "none of the $EXP_COUNT [Experimental] types appear in any PublicAPI.Shipped.txt" else result 9 experimental-not-promised FAIL "experimental types in the stable promise:$LEAKED"