Repository navigation
Wave 3 and the 0.2.0 release commit: two security fixes, fail-closed trust defaults, API tracking, pinned runners - #688
Merged
Merged
Conversation
…ns today Every job behind master's five required checks ran on a floating label. From 2026-10-19 GitHub starts moving ubuntu-latest from Ubuntu 24.04 to Ubuntu 26.04 (actions/runner-images#14748, rollout to 2026-11-19), and with it the tools under those checks: shellcheck 0.9.0 -> 0.11.0 (what shell-lint runs), Python 3.12.3 -> 3.14.4 (every repo gate), Docker Compose 2.38.2 -> 5.1.3 (image readmes ubuntu24/20260927.320 vs Ubuntu2604). None of that would be a commit here, and for the month of the rollout two runs of the same commit could land on different images. A required check that goes red that way blocks every pull request (enforce_admins=true) with a diff that explains nothing. The pins are behaviour-neutral: each equals what its -latest label resolved to on 2026-09-30, read from the "Set up job" step of successful runs (gh run view --job <id> --log): ubuntu-latest -> ubuntu-24.04 "Image: ubuntu-24.04", Ubuntu 24.04.5 LTS, builds 20260920.314.1 and 20260927.320.1: cert-gate run 36775242663, build-core 36775242669, shell-lint 36767106261, lychee 36775242741, and every Linux, container, docker and summary readiness job of runs 36713219284 (push, all lanes) and 36767106378 macos-latest -> macos-26 "Image: macos-26-arm64" 20260907.0351.1 (macOS 26.6.2), both readiness runs windows-latest -> windows-2025-vs2026 "Image: windows-2025-vs2026" 20260925.250.1 (Windows Server 2025), both readiness runs The runner-images README (a99056ad72b0) maps each pinned label to that same image. It lists windows-2025 for it too, but that label moved from the VS2022 image to the VS2026 one in June 2026 (actions/runner-images #14017, rolled out 2026-06-08 to 2026-06-15; #14004 saw jobs moved from 2026-05-05; the README only caught up on 2026-07-06 in 69bb04343d98), so the pin names the Visual Studio image as well. Prevention: scripts/ci/verify-required-runner-pins.py, discovered by run-repo-gates in required shell-lint. It judges the runs-on of EVERY job in the five required workflows (Readiness summary needs all the lanes), resolving ${{ matrix.os }} to each matrix value, and a runner-group mapping by its labels:, and accepts only a whole label shaped ubuntu-NN.NN[-arm], macos-NN[-intel|-large|-xlarge] or windows-NNNN-vsNNNN. It refuses any -latest in any case, windows-NNNN alone, a label that only starts like a pinned one (ubuntu-24.04-16core, the image name macos-26-arm64), an expression it cannot evaluate (including a matrix or an include: built from one), an empty runner list, a job with no runs-on, a reusable-workflow job and any unknown label. The only exception is a reasoned `# runner-pin: allow <reason>` YAML comment on the label's own line. PyYAML keeps no comments, so the check finds each line's comment by elimination against the YAML scanner's scalar spans: the same text inside a quoted value or a run: script is data, excuses nothing and is refused, as are stale, stray, malformed and reasonless comments. It fails closed when a required workflow or its required job is missing, and run bare it needs at least 10 judged labels and an ubuntu, a macos and a windows one. tests/scripts/required-runner-pins.test.sh has 50 assertions, among them a clean-fixture control, a control that a real comment after a quoted '#' is still honoured, and a real-tree control that each of the five files and the macOS and Windows lanes were reached. 23 mutations (two workflow reverts, 21 checker faults, one per refusal branch) were each observed red on this commit. The three docs that cite these labels describe environment-setup-gate-v1 and production-readiness-gate-v1, which are advisory and still float. They now say that, and what the labels resolved to, instead of claiming a pin those workflows do not have. What this does NOT do: pin any other workflow (a later workstream owns them); pin the image build (GitHub pins only the family, so weekly rebuilds still arrive); pin what jobs install themselves (setup-dotnet 10.0.x, the apt shellcheck fallback) or container tags (mcr.microsoft.com/dotnet/sdk:10.0); prove the pinned labels in a run. The first CI run of this change is that proof - every readiness lane runs because the readiness workflow is in its own path list. Follow-up: trial ubuntu-26.04 on a branch (the check accepts it as a pinned label) and fix what shellcheck 0.11 and Python 3.14 report there, then move the pin as its own reviewed change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d in
Fleet.Host's pipeline was copied from Ashlar.API without the API's Swagger
gate, so it called UseSwagger/UseSwaggerUI unconditionally. It now uses the
API's gate with the same semantics: on in Development, otherwise only when
Ashlar:Api:EnableSwagger (Ashlar__Api__EnableSwagger) is true, and an
explicit false turns it off in Development too. Turning it on outside
Development logs the same Ashlar.Security notice the API logs. The shipped
appsettings.json now carries "Api": { "EnableSwagger": null }, as the API's
does, so the setting is visible there.
What an attacker could reach before. I published the pre-fix tree
(f0fd3fd) the way .docker/Dockerfile.fleet-host does and started it the
way the image does, with no ASPNETCORE_ENVIRONMENT, so it ran as
Production. Anyone who could reach its HTTP port got, with no credential:
- GET /swagger/v1/swagger.json: 200, the OpenAPI document (139119 bytes).
- GET /swagger/v1/swagger.yaml: 200, the same document as YAML (93765
bytes). Swashbuckle 6.5.0 serves both formats from one route, and
/swagger/v1/swagger.yml answered 200 with the JSON too.
- GET /swagger/index.html: 200, the Swagger UI page, and 200 for the
stock Swashbuckle UI files it loads (index.js, swagger-ui.css,
swagger-ui-bundle.js, swagger-ui-standalone-preset.js,
oauth2-redirect.html, favicon-32x32.png).
- GET /swagger and /swagger/: 301 to /swagger/index.html. These redirect
to the UI; they are not pages of their own.
No auth setting closed it: AshlarApiKeyAuthMiddleware and
MeshSecurityMiddleware both pass any path outside /api through before they
look at AuthorizationMode or AuthorizationScope. The document described 68
paths and 73 operations (38 GET, 32 POST, 1 PUT, 1 PATCH, 1 DELETE) with
112 schemas. That includes the fleet control routes: node register, admit,
revoke and drain; mesh task schedule, retry, lease and migrate; and
knowledge export and import.
What it did not give them. The document holds no data, configuration values
or credentials. Its top-level keys are openapi, info, paths and
components.schemas only. It grants no access either. Every route it lists
enforced the same auth whether or not Swagger was served, and the UI's
"Try it out" sends ordinary requests that the same middleware judges. So the
exposure was reconnaissance: a complete, typed map of the host's attack
surface, including which routes the shipped MutatingApi scope leaves
anonymous for GET. docs/DEPLOYMENT.md already said production images ship
with Swagger off, which was false for this image until now and is true now,
so the doc needs no edit.
After the fix, the published artifact (a2b84cea, whose product code this
commit keeps) answers 404 on the JSON, the YAML, /swagger, /swagger/ and
/swagger/index.html in its default Production environment and in Staging
(measured).
One environment still serves it, and this change does not close it. The
ProjectReference to Ashlar.API copies Ashlar.API's appsettings.Development.json
and appsettings.Testing.json into Fleet.Host's build and publish output,
byte-identical to the API's; Fleet.Host's own appsettings.json wins for the
base file. appsettings.Testing.json sets Ashlar:Api:EnableSwagger=true, so
the fleet-host image started with ASPNETCORE_ENVIRONMENT=Testing serves the
JSON, the YAML and the UI (measured 200 on the published a2b84cea
artifact, whose product code this commit keeps). The same file also
sets AwsSnsSkipSignatureVerification=true and Logging Default=Warning, and
the Warning level hides the Ashlar.Security notice. No deployment sets
Testing. Keeping Ashlar.API's appsettings files out of this host's output is
a build change this commit does not make; the tests pin the exception so
that it cannot grow quietly.
Tests. FleetHostSwaggerExposureTests starts the host through the project's
existing WebApplicationFactory pattern. Its client does not follow
redirects, so a served bare /swagger shows up as the 301 it is.
- Production with the shipped config, Staging, Production with an explicit
false, and Development with an explicit false: the JSON, the YAML, the
UI page and the bare /swagger each return 404, and no Ashlar.Security
notice is logged. A failure lists every path that answered.
- Production and Staging started from Fleet.Host's own build output as
content root, the way the image runs from its publish directory: the
same 404s. The factory's default content root is the source directory,
which never sees the files inherited from Ashlar.API; the build output
does. The case checks that the host really used that content root, and
that the directory's appsettings.json is identical to the host's own. The
test assembly's own output directory would not do: there Ashlar.API's
base appsettings.json overwrites Fleet.Host's (measured).
- An inventory of the build output's appsettings.*.json: the only
environment whose effective EnableSwagger is true is Testing. Adding
another fails it, and so does removing Testing's, so either change is
reviewed.
- Development, and Production with the opt-in: the JSON and the YAML are
this host's document, the UI is text/html, the bare /swagger is a 301 to
swagger/index.html, and the Ashlar.Security notice is logged once in
Production and never in Development.
Each hosted case first asserts /health is 200 on the same host, so a 404
cannot come from a host that failed to start.
Mutation-checked in the devtest container (net10.0), one mutation per
commit in a throwaway clone, whole suite (16) each time:
- The pre-fix Program.cs: 7 fail. The six not-served cases each report
"swagger.json answered 200; swagger.yaml answered 200; index.html
answered 200; /swagger answered 301", and Production+true finds no
Ashlar.Security notice.
- Ignoring the config (IsDevelopment only): Development+false and
Production+true fail (2).
- GetValue<bool> || IsDevelopment: Development+false fails (1).
- "?? !IsProduction()": both Staging cases fail (2).
- Shipping "EnableSwagger": true: 5 fail, the four Production/Staging
cases and the inventory.
- UseSwaggerUI outside the gate: the six not-served cases fail on
"index.html answered 200; /swagger answered 301".
- An unconditional YAML-only UseSwagger: the six not-served cases fail on
"swagger.yaml answered 200" alone, so the YAML path is asserted in its
own right.
- The verifier's M8, application/src/Ashlar.API/appsettings.Production.json
with EnableSwagger=true: the build-output Production case and the
inventory fail (2). The source-directory Production case stays green,
which is why the build-output case exists.
- The verifier's M9, deleting the Ashlar.Security log block:
Production+true fails, expected 1 notice, found 0.
- Logging the notice in Development too: Development fails, expected 0,
found 1.
- Removing EnableSwagger from Ashlar.API's appsettings.Testing.json: the
inventory fails (found empty), the other direction of the pin.
- Using the test assembly's own output directory as the image stand-in:
the three build-output cases fail on the appsettings.json identity check
(it holds Ashlar.API's file there).
- Dropping UseContentRoot: both build-output cases fail on the content
root check.
Before and after the batch, the unmutated tree passes 16/16.
The private-license gate is not added here. Program.cs records why: this
host registers neither IPrivateLicenseValidator nor the Ashlar:PrivateLicense
options, so EnforceLicense has no effect on it.
What this does NOT fix:
- ASPNETCORE_ENVIRONMENT=Testing on the fleet-host image, described above.
- The gate now exists in two copies, in Ashlar.API and in Fleet.Host. A
shared helper would stop them drifting apart again. It needs an
application/ edit that this change does not make.
- When Swagger is opted in, it is still served without auth, exactly as on
Ashlar.API.
- While measuring I found another fault. GET /api/support/diagnostics on
Fleet.Host throws "No service for type IPrivateLicenseValidator" when
called with a valid API key (401 without one), which is a 500 on a real
server. It is untouched here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed's sibling-prefix escape WHAT THE ESCAPE ALLOWED. OutputPathSandboxed approved a tool call when Path.GetFullPath(output).StartsWith(Path.GetFullPath(OutputRoot), OrdinalIgnoreCase) held, with no directory separator after the root. Any output whose full path merely BEGINS WITH the root's characters therefore passed: with OutputRoot /srv/app/out it approved /srv/app/out-evil/x, /srv/app/outx and the file /srv/app/out.dll - every entry in the root's PARENT directory whose name extends the root's last segment, however spelled (/srv/app/out/../out-evil/x reaches the same place). It did not admit an arbitrary '..' escape (GetFullPath collapses those before the comparison) or an unrelated directory. On Linux the ignore-case comparison additionally admitted a case variant of the root (/srv/App/out/x), which there names a different directory; PathAllowlist's absolute-path leg had that case half of the defect too. The escape is live: AgentExecutorAdapter (the IAgentExecutor behind RunAgentHandler) composes OutputPathSandboxed with AllowAllPolicy and sets OutputRoot to <cwd>/out, so a model-chosen assembly.decompile call with output "<cwd>/out-evil" (or "outx", relative) passed the policy engine, and that tool creates the directory and writes <name>.decompiled.cs into it: a write into the process working directory beside the sandbox, not under it. Proven first against unmodified code (Linux, net8.0): 8 of 39 PathContainmentTests cases failed - 7 OutputPathSandboxed rows (6 escapes admitted: sibling directory, file and bare name, dot-dot-to-sibling, a backslash-dot-dot suffix, the Linux case variant; plus a root spelled with a trailing separator falsely refused) and 1 PathAllowlist row (the Linux case variant) - and the new Kernel sibling fact failed. WHY A HELPER. There were ten hand-written containment checks in production code with five semantics: no separator (OutputPathSandboxed); strictly-inside ordinal (MediatedWritePath); root-inclusive ignore-case everywhere (WorkspacePathPolicy, SnapshotManagedFileSet, PathAllowlist, RepoFsListTool, RepoFsReadTool); root-inclusive ordinal (ProjectVerifier); root-inclusive and platform-dependent case (ToolSandbox: fold on Windows and macOS; AshlarEndpoints: fold on Windows only). Ashlar.Abstractions.Paths.PathContainment is now the one definition: GetFullPath on both arguments, refuse anything unresolvable or still carrying a '.'/'..' segment (only a Windows \\?\ path can), drop trailing separators (never below the filesystem root), then equal-or-starts-with-root-PLUS-SEPARATOR. IsWithin counts the root itself; IsStrictlyWithin does not. Case follows the platform's default file system (OrdinalIgnoreCase on Windows and macOS, Ordinal elsewhere) unless the caller passes Ordinal/OrdinalIgnoreCase; culture comparisons throw. It lives in Ashlar.Abstractions because that is the lowest project every copy can reach without a new ProjectReference (Ashlar.Policies references only Abstractions), written to the netstandard2.0 surface; its five members are in PublicAPI.Unshipped.txt. Converted, with each site's semantics kept except where the old one was the defect: OutputPathSandboxed (separator; Linux now case-sensitive), PathAllowlist absolute leg (Linux now case-sensitive), WorkspacePathPolicy (Linux case-sensitive; a root configured with a trailing separator now contains itself), SnapshotManagedFileSet, ToolSandbox (identical semantics; its private copy and PathComparison are gone), RepoFsListTool and RepoFsReadTool (their own TryResolveRelative copies have no caller at all - both tools resolve through ToolSandbox.TryResolvePath - so converting them changes nothing reachable), MediatedWritePath (IsStrictlyWithin, Ordinal on every platform, both the containment leg and the reparse-walk bound; a target resolving to the root WITH a trailing separator, "./" or "a/../", is now refused as an escape like "." already was, instead of as an unsafe shape), and TempDirTestBase's unused temp-dir assertion (same missing-separator defect). OutputPathSandboxed also refuses a non-string "output" (5, true, an array, an object) instead of throwing InvalidOperationException out of Approve: GetString moved inside the existing try. That throw predates this change. PREVENTION, BEHAVIOUR FIRST. PathContainmentTests (cert-gate) runs a 20-row absolute table against the helper, OutputPathSandboxed and PathAllowlist, and a 10-row RELATIVE table against the sites that resolve a model-supplied path against a sandbox root: ToolSandbox's read resolver, repo.fs.read and repo.fs.list on every row, and its write resolver on every escape row plus an ordinary-child control. The relative table builds <stem>/repo with real siblings <stem>/repo-evil/x.cs, <stem>/repox and <stem>/repo.dll. A separator-less helper put back into ToolSandbox (here.StartsWith(there, PathContainment.PlatformComparison)) turns 15 of its rows red, and repo.fs.read is seen returning the sibling's contents; before this table that reintroduction left every suite green. The write-resolver rows stay green under it because MediatedWritePath checks containment again, and they pin that it does. PathContainmentConventionTests (cert-gate) is the ratchet: every production .cs file's count of hand-rolled checks must EQUAL its pin (two remain: AshlarEndpoints.cs 2, ProjectVerifier.cs 1), so a new copy and a stale pin both fail. A .StartsWith( counts when its non-literal first argument or its receiver has full-path provenance, when it compares with PlatformComparison, or when its interpolated literal argument embeds such a path. An expression has provenance when it spells GetFullPath(, a directory separator, or new FileInfo(/new DirectoryInfo(, or names an identifier that (1) is assigned from such an expression, (2) is assigned ONE path-building step from one (Path.Combine/Join, a concatenated separator literal, an interpolated string with a separator in it, a plain or trimmed alias; a Substring is relative again and drops it), or (3) is a parameter of a method declared in the same file that a call in the same file passes such an argument. Comments and literal contents are blanked first; interpolation holes are kept. Measured with a Python port and confirmed by the C# scan: at the base exactly the 13 calls in the ten copies match out of 126, and the new rules add no match anywhere in the repository, including test projects (3,149 tracked files, 227 calls). With the eight converted files put back, the C# ratchet lists exactly those eight (10 calls). Full transitivity was measured and rejected: it flags MediatedWritePath's relative allowlist and a proposal target-prefix filter. 38 controls pin the classifier (19 must count, 16 must not, 3 known misses pinned at zero). Test projects are now skipped by their Microsoft.NET.Test.Sdk reference rather than by a directory name containing "Tests", which had skipped the production use case src/Ashlar.Core.Application/Testing/UseCases/RunTests; products/ and tools/ are now scanned (8 more files, no new hit). WHAT THIS DOES NOT FIX. The helper is lexical: it does not follow symlinks or junctions (a fact pins that; MediatedWritePath keeps its own reparse-point probes), does not model a case-sensitive volume on Windows/macOS, and does not unify 8.3 names, \\?\ against plain spellings, mapped drives or bind mounts. The scan's provenance is by identifier NAME within one file, not by type, scope or data flow across files, so it cannot see a helper whose callers live in another file (static bool Under(string a, string b) => a.StartsWith(b, StringComparison.Ordinal)), a root that comes back from a method call (candidate.StartsWith(root + "/") with root = ResolveRoot(...), which is textually the relative-allowlist idiom it deliberately ignores), the FullName of a FileInfo the code receives rather than constructs, or IndexOf(root) == 0, string.Compare and Uri.IsBaseOf. The first three are pinned as known-miss controls. The first draft of this change said the scan's only blind spot was a two-parameter helper with no path construction beside it; that was wrong, because provenance was by name, and a separator-less helper in a file full of GetFullPath calls went unseen. The tables, not the scan, are the proof. Not converted: application/src/Ashlar.API/Endpoints/AshlarEndpoints.cs (would make this src/ change span application/, which layer-boundary refuses without [coordinated-integration]; already separator-aware) and src/Ashlar.Manifest/ProjectVerifier.cs (Ashlar.Manifest references no project; already separator-aware). OutputPathSandboxed still resolves a RELATIVE output against the process working directory rather than OutputRoot, and still guards only the "output" argument. WorkspacePathPolicy still has no direct test (its only constructor reads WORKSPACE_ROOT, a process-global variable a cert-gate test cannot set without racing parallel tests); the helper table covers its logic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing and deprecation suppressions
WHY. 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 file. The five
files under src/Ashlar.Brick.Contracts/Authoring/Ports/ opened with a file-wide
`#pragma warning disable RS0016` that was never restored ("Profile ports ship via
Unshipped incrementally; suppress until batched" - the batch never came). None of
their lines was in either PublicAPI file, so 18 public types - AgentProfile,
AgentProfileCapabilities, GenerationTunables, BrickConstraintManifest,
GenerationRequest, GeneratedArtifact, AcceptanceDecision, DeploymentSmokeResult,
AcceptanceContext, AcceptanceResult, IAcceptanceEvaluator,
DefaultAcceptanceEvaluator, IAcceptanceGatedDeploymentTarget, IArtifactDrafter,
IDeterministicDrafter, ISandboxProvider, IDeploymentTarget, DeploymentApplyResult
(namespace Ashlar.Core.Domain.Bricks.Ports) - were promised by nothing, and any
change to them built green. With 0.2.0 about to publish, the promise in the policy
was false for them.
SHIPPED, NOT UNSHIPPED - the evidence.
- `git ls-tree v0.1.0|v0.1.1|v0.1.2 -- src/Ashlar.Brick.Contracts/Authoring/Ports/`
lists all five files at every tag, and both `git diff v0.1.0 v0.1.2 -- .../Ports/`
and `git diff v0.1.2 f0fd3fd -- src/Ashlar.Brick.Contracts/` are empty. Every one
of these symbols was public, unchanged, in three tagged releases the policy
records as published. That is a promise already made: Shipped.txt.
- Nothing in this surface is new since 0.1.2, so nothing goes in Unshipped.txt, and
that is also what keeps the 0.2.0 tag commit right. The 0.2.0 cut is already on
master (#628 set VERSION=0.2.0; #678 "release: cut 0.2.0" set
ci/published-version=0.2.0 and recorded that PublicAPI promotion was a no-op),
and RELEASE_RUNBOOK step 7's promotion has already left every Unshipped.txt at
the bare header - no PublicAPI file has changed since. A line put in
Unshipped.txt now would reach the 0.2.0 tag unpromoted unless someone re-ran
step 7, and the tag is what turns a line into a promise; so a symbol genuinely
new since 0.1.2 would belong in Shipped.txt as well. There is none here.
- The 147 lines are the analyzer's own, not hand-written: read from the RS0016
diagnostics' APIName property (SARIF via -p:ErrorLog), 144 names identical for
netstandard2.0, net8.0 and net10.0, plus the three positional-record primary
constructors the analyzer only reports once their types are declared. `dotnet
format analyzers --diagnostics RS0016` applied no fix to this multi-targeted
project (measured; also single-TFM), so it was not used. The lines carry nullable
annotations (`string!`) as the analyzer emits them and as the Abstractions,
Client, Sdk and Authoring files already do; the older oblivious Brick.Contracts
lines are left alone. Shipped.txt: 465 -> 612 lines.
- Not [Experimental]: demoting a shipped stable API is a breaking change under the
policy, and it would not be true - these are in use as a stable contract.
THE DEAD CS0618 PRAGMA. 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 into AshlarSdkBuilder.cs and the pragma
stayed, suppressing nothing for 88 days while reading as a reason - and silencing
in advance the next obsolete call anyone wrote into the file. Deleted. Measured:
Ashlar.Hosting builds with 0 warnings / 0 errors without it on net8.0 and net10.0.
THE PREVENTION: DiagnosticSuppressionConventionTests (cert-gate namespace, row in
ci/cert-gate-assertions.md).
- Rule 1: nothing switches public-API tracking off. No source anywhere disables
RS0016/RS0017/RS0026/RS0027 by pragma or [SuppressMessage]/
[UnconditionalSuppressMessage], and no source has a bare `#pragma warning
disable`; no MSBuild file lists those ids in NoWarn/WarningsNotAsErrors; no
.editorconfig/.globalconfig lowers them to none/silent/suggestion or sets
`dotnet_public_api_analyzer.skip_namespaces`, and none that applies to an
analyzer project lowers the ApiDesign category or every analyzer; no analyzer
project or Directory.Build.* above one sets RunAnalyzers(DuringBuild) false or
removes an API file from AdditionalFiles; and each of the 6 projects referencing
the analyzer wires both API files as AdditionalFiles. The source scan is
tree-wide on purpose: a pragma acts on its own file, files are linked into these
projects from outside them, and an RS0016 pragma anywhere else is dead by
definition.
- Rule 2: a `#pragma warning disable` of CS0618 or ASHLAREXP001 - in every spelling
the compiler accepts, which for CS0618 includes the bare number: Roslyn maps a
numeric pragma id (and a numeric /nowarn id) to "CS" + the number padded to four
digits, so `618` and `0618` ARE CS0618 - must name, as an exact identifier, an
in-tree [Obsolete] (CS0618 - not error: true, no DiagnosticId) or
[Experimental(ASHLAREXP001)] symbol; at least one named symbol must be used in
the code the pragma governs OUTSIDE a declaration carrying the same attribute
(the compiler reports neither diagnostic in an obsolete/experimental context);
and the comment must give a reason of at least three words. Tree-wide, because
the defect it exists for was in Ashlar.Hosting, which does not use the API
analyzer.
- Rule 2 also covers the disables that cannot carry such a comment: a
[SuppressMessage]/[UnconditionalSuppressMessage] naming either id; a
NoWarn/WarningsNotAsErrors element or attribute listing either id (any
spelling) in any MSBuild file; and a `dotnet_diagnostic.<id>.severity` of
none/silent/suggestion in any analyzer config. A project-wide disable names no
symbol by construction, so every one must be on ReviewedProjectWideDisables,
which records why. Two exist and both are deliberate, documented opt-ins:
Directory.Build.targets:32 (test projects exercise the experimental surface;
the diagnostic exists for external consumers) and the FirstFlight spike's
csproj:14 (the spike is a consumer, outside Ashlar.sln and every gate).
- Eight existing pragmas fail Rule 2 in files outside this change; they are
grandfathered by exact text, each with what clears it. Both lists fail in both
directions (a new violation, or an entry that no longer matches), so each can
only change by a reviewed diff. Compiler ground truth, measured by deleting each
pragma and building on f0fd3fd: both Ashlar.Sdk/Legacy CS0618 pragmas are DEAD
(Ashlar.Sdk builds clean - every use sits inside an [Obsolete] context); the
test one is live but uncommented; all six ASHLAREXP001 pragmas are live, and
five of them name no symbol ("touch-set", "lineage"). ObjectiveDocument.cs
already complies and is asserted to pass.
- Stated floors, not derived ones (2500 C# files, 100 MSBuild files, 6 analyzer
projects, 40 experimental names, 1 obsolete name, 10 disable pragmas; measured
3149, 133, 6, 65, 2, 15). The obsolete floor is 1, under the measured 2, so that
retiring one forwarder - the normal end of a deprecation - is not reported as a
broken parser; the real-tree anchors (AshlarSdkBuilder, TouchSet, ...) say in
their failure messages that a removed or graduated symbol means "move the
anchor", not "the parser is broken". Controls over in-memory text pin every
banned spelling (including 618/0618 and a numeric restore), immunity to
comments and literals (including raw and verbatim strings), the obsolete-context
rule, CS0619/DiagnosticId exclusion, the project-wide and attribute forms and
what they must not catch (CS8618, 6180, WarningsAsErrors, a commented-out
NoWarn), and the shipped HostAshlarSdkBuilder.cs verbatim as a dead pragma in
all its spellings.
VERIFIED in the devtest container on the committed tree:
- Builds, 0 warnings / 0 errors: Brick.Contracts on netstandard2.0, net8.0 and
net10.0; Hosting on net8.0 and net10.0; Tests.Infrastructure on net8.0 and
net10.0. Ashlar.LocalDevCore.slnf (what build-core builds) restores and builds,
0 errors. The class passes 96/96 on net8.0 and on net10.0 (TRX executed=96).
scripts/run-cert-gate.sh exits 0: total=1410 executed=1410
passed=1410 failed=0 (the class's 96 among them), zero-test guard 1410 against
expected>=1405 from --list-tests, skip guard 0 skipped, matching
scripts/cert-gate-skipped.baseline.
- Mutations, each proven applied by the original string's count moving, each
restored to an empty `git status --porcelain`, then rerun green:
* the shipped dead pragma back at HostAshlarSdkBuilder.cs:7 as
`#pragma warning disable 618 // AshlarSdkBuilder is ...` -> the Rule 2 fact
fails (95/96): "HostAshlarSdkBuilder.cs:7: '#pragma warning disable 618 ...'
- CS0618: is dead". Review measured the first round passing 63/63 on it.
* a new `new AshlarSdkBuilder(null!)` in Ashlar.Hosting under a bare
`#pragma warning disable 618` -> Hosting builds exit 0 (the compiler IS
silenced) and the Rule 2 fact fails: "ApivProbe.cs:1: '#pragma warning
disable 618' - CS0618: carries no comment". Without the pragma, Hosting
fails with CS0618 on net8.0 and net10.0 (2 errors).
* `<NoWarn>$(NoWarn);CS0618</NoWarn>` in Ashlar.Hosting.csproj with that use
present -> Hosting builds exit 0 and the project-wide fact fails naming
"Ashlar.Hosting.csproj:9"; spelled `618`, it fails the same way.
* `dotnet_diagnostic.CS0618.severity = none` in the root .editorconfig -> the
project-wide fact fails naming ".editorconfig:188".
* FirstFlight's reviewed NoWarn deleted -> the project-wide fact fails: "is on
the reviewed list but no longer found".
* an assembly-level [SuppressMessage("Usage", "CS0618:...")] -> the Rule 2 fact
fails naming it.
* numeric-id normalisation disabled -> exactly the 9 numeric-spelling controls
fail (87/96); the project-wide element scan disabled -> the real-tree
project-wide fact fails with BOTH reviewed entries stale, plus the 5 element
controls (90/96).
* from the first round, still standing: RS0016 pragma back on AgentProfile.cs:1
-> Rule 1 fails naming it; a new public property on GenerationTunables ->
Brick.Contracts fails RS0016 for Extra.get/Extra.init on all three TFMs, and
the SAME change with the old pragma restored builds 0/0 - what the
suppression hid; one Shipped.txt line removed -> RS0016 on all three TFMs;
RS0016 in the project's NoWarn -> Rule 1 fails; a Ports-scoped
skip_namespaces -> the property's RS0016 disappears (the option really
untracks) and Rule 1 fails naming the line; the eight grandfathered pragmas
rewritten as their notes say -> eight "no longer violates", and with the
list emptied it passes; declaration parser broken -> the floor fires;
obsolete-context rule removed -> exactly its control fails; literals not
blanked -> six facts fail.
- scripts/ci/run-repo-gates.sh: all 21 gates pass. Knowledge graph regenerated
(declared facts 4399 -> 4414) and verified current.
WHAT THIS DOES NOT FIX.
- The eight grandfathered pragmas (two to delete, six to name their symbols) and
docs/SdkCompatibilityPolicy.md, which still quotes 465 Shipped lines for
Brick.Contracts and does not list the Ports types - all outside this change.
- Rule 2 is lexical: it proves a disable names a real symbol that the governed code
still mentions outside that symbol's own context, not that the compiler would
report there (a lambda inside an obsolete member, nameof, a same-named symbol
from another assembly all read as uses). An obsolete API declared outside the
tree cannot be named and needs a reviewed grandfather entry. 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. TreatWarningsAsErrors,
WarningLevel and the bulk `dotnet_analyzer_diagnostic.*` settings are not
checked for Rule 2. Rule 1 follows no imports beyond the Directory.Build.*
chain and does not treat an <auto-generated/> header as a suppression.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r is missing
Every place the runtime met a MISSING sensitivity fact filled it in with the value that widened
access. This inverts each default, and the tests that pinned the open defaults as the contract.
A clearance and a label fail closed in OPPOSITE directions, and the old code applied one rule to
both, so it was open both ways:
- An omitted or unrecognised CALLER clearance is now the FLOOR (Public). MeaiVectorDataRagAdapter
-- the IRAGService the shipped hosts resolve -- turned an omitted clearance into "TopSecret", so
`rag search` without --max-sensitivity returned the whole corpus, and the rag:search audit then
recorded caller_tier=TopSecret as if the caller had been cleared. TrustTierOrder ranked any unknown
caller tier ("Unclassified", a typo) as TopSecret. The legacy InMemory/Sqlite stores applied no
filter at all unless a registry was supplied AND the clearance resolved. The audit now records the
clearance actually applied and why: caller_tier=<applied> caller_tier_basis=explicit|omitted|
unrecognised.
- An unlabelled or unrecognised RECORD is now the MOST RESTRICTIVE level. TrustTierOrder ranked a
blank tier as Public, ChunkRecord.TrustTier defaulted to "Public", the adapter indexed an omitted
label as "Public", the legacy stores returned unmarked documents to everyone, and
DataSensitivityMarker reported unmarked data as Public. "Most restrictive" is the registry's
highest level (never below TopSecret), so a custom level above TopSecret is honoured. No label is
invented: an unlabelled record is stored unlabelled and reported as null.
- `rag index` REFUSES without --sensitivity (and refuses an unknown one). Defaulting to the top was
rejected: a knowledge base indexed that way is invisible to every search below the top clearance,
so the omission would surface later as "search finds nothing" with no pointer to its cause. The
library layer underneath still stores an omitted label as unlabelled (= top) for programmatic
callers. `rag search` refuses an unknown --max-sensitivity instead of flooring it silently, and
reports the clearance it searched at.
- Re-indexing an existing key at a LOWER label is refused, in VectorDataRagService and in both
legacy stores (inside the write's transaction for Sqlite). The indexer keys on the full file
path, so re-running an index with a lower label silently downgraded previously Secret content.
Lowering a label is now a separate act: remove, then index. An unlabelled stored record counts as
the top, so it cannot be relabelled downward by re-indexing either.
- DataExfiltrationPolicy holds an agent it cannot identify (no, blank or non-string agentId, or an
id the registry does not hold) to the MOST restrictive policy instead of approving with "OK". Its
scope is unchanged: it only ever refuses the exfiltration-class tool ids, so a local tool is still
approved -- an unidentified agent is treated as the most restricted registered agent, not as
"deny everything", which would also have stopped `ashlar self-extend`, whose snapshot agentId is
the role name and is not a registered agent. A REGISTERED agent whose config or policy is null
gets the most restrictive policy too (that line used to throw a NullReferenceException).
- CloudSanitizationProxy BLOCKS (and audits the block) when it has no content filter, instead of
passing every prompt through unaudited. That was the state `ashlar improve` ran in with
ASHLAR_TRUST_ENABLED=1: it registered the proxy by type, DI built it with every optional
dependency null, and "trust enabled" wrapped the provider in a sanitizer that sanitized nothing.
improve now takes the kernel's trust composition (AddTrustServices, provider registration skipped
because improve builds that chain itself). A redaction is also audited once, not twice.
- BackgroundAgentConfigLoader's level-to-policy derivation could not fire: it waited for a blank
MaxAllowedLevel, and an absent ExfiltrationPolicy section binds to defaults whose MaxAllowedLevel
is "Public". A TopSecret agent with no section ran with every Block flag false; the only test of
the derivation passed because it WROTE a section with a whitespace MaxAllowedLevel. The loader now
binds agents one at a time and detects the section's absence. That test is replaced by ones that
omit the section; the old trigger is kept (and pinned) so nothing that used to derive stops.
Binding per agent keeps, element by element, the outcome the list bind gave -- measured on both
for twelve element shapes: a value the binder cannot convert ("Enabled": "notabool") or a scalar
where an agent belongs leaves THAT agent out and the rest load; an empty element ({} / null) is
still refused with "Agent ID is required". The one difference is that a left-out agent is now
logged as a warning naming its configuration path; the list bind dropped it silently.
Side effect, disclosed: the derived policy's MaxAllowedLevel is the agent's MaxDataSensitivity,
where an absent section used to leave the default "Public". AgentPolicyNarrowingValidator bounds
a machine-spawned child's MaxAllowedLevel and AllowedDataSensitivityLevels by that value, so the
child of a no-section parent at Internal or above may now carry levels up to the parent's own
MaxDataSensitivity (before: refused above Public). For an Internal parent, which derives no Block
flags, that is a pure loosening; nothing outside the validator enforces MaxAllowedLevel.
- RAGTool: the model's maxSensitivityLevelName can now only NARROW the agent's clearance from the
snapshot, never raise it, and with no agent clearance the search runs at the floor. Latent (no
production construction), but it was a prompt-injection route to TopSecret the day it is wired.
- AgentPolicyNarrowingValidator gets an isolating test for its level compare: the existing rejection
test was refused on BlockExternalLLMs first, so deleting the compare left every test green.
Inverted pinning tests: DataSensitivityMarkerTests (unmarked -> Public, CanAccess unmarked -> true),
CloudSanitizationProxyTests (no filter -> allowed), DataExfiltrationPolicyTests (no agentId / unknown
agent -> allowed "OK"), BackgroundAgentConfigLoaderGapCoverageTests (the whitespace tripwire).
New pins: a registered agent with no policy is denied; a malformed or scalar agent element is left
out with a warning while its neighbour loads; an empty element is refused.
Legacy-store tests that indexed unmarked documents as incidental setup now label them Public, so
each still tests what its name says instead of passing on the new sensitivity filter.
Every behavioural change was mutation-checked in the devtest container: the mutation proven applied
(original text count 1 -> 0), the named tests observed red, the clone restored clean.
What this does NOT fix (later, larger steps): compartments, principals and no-write-down; the
exfiltration tool lists are still DENY-lists (an exfiltrating tool with an unlisted id passes); an
agent's own ModelProvider is not checked against its sensitivity; the taxonomy is still not
consulted by the sanitization proxy; peer:* targets, the router's cloud fallback, barrier-header
precedence, the AirGapped profile's model egress and the 24h cross-agent observation feed are
untouched; custom sensitivity levels are ranked by the legacy stores but not by the MEAI store's
fixed five-tier scale (there a custom record label ranks at the top and a custom caller clearance
at the floor -- closed, not correct); an ExfiltrationPolicy section that IS present but weaker than
the agent's MaxDataSensitivity implies is still honoured as written; the loader catches only the
binder's InvalidOperationException for a malformed agent (the list bind swallowed any exception
type; the bound config types have no custom setters, so a malformed value surfaces as that one).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each of the five workstreams below refused to edit a file outside its
ownership and listed the text its change made false instead. This applies
those corrections in one place. Docs and comments only; no behaviour changes.
- docs/GapAnalysis.md: section 4 said improve's trust wiring was resolved, but its proxy had no filter until fail-closed; the test it recommended now exists.
- docs/ExecutionPlan.md: C.6 rows and section described the same wiring and listed its test as remaining; now Tested, with the test linked.
- docs/meai-migration-notes.md: the proxy now blocks when it has no filter, and it never consulted the taxonomy ("may further constrain" was false).
- docs/SdkCompatibilityPolicy.md: Brick.Contracts Shipped.txt is 612 lines, not 465 (147 Ports lines); Abstractions' Unshipped.txt now holds the six PathContainment lines; the 18 Ports types and the suppression convention are listed.
- docs/DEPLOYMENT.md: Fleet.Host now honours Ashlar__Api__EnableSwagger, and ASPNETCORE_ENVIRONMENT=Testing serves Swagger in all three images through Ashlar.API's appsettings.Testing.json.
- docs/Configuration.md: the EnableSwagger row now says Fleet.Host honours the key, and names the Testing exception.
- .docker/Dockerfile.fleet-host: the header said Fleet.Host targets net8.0 on an 8.0 runtime; the csproj targets net10.0 and the runtime stage is aspnet:10.0.
- shell-lint.yml and full-platform-readiness-gate.yml: two comments still named ubuntu-latest for jobs now pinned to ubuntu-24.04 (comment text only).
- CHANGELOG.md [Unreleased]: the wave's user-visible changes, including the disclosed MaxAllowedLevel loosening for agent configs with no ExfiltrationPolicy section.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and refuses an empty scan
Tier 9.2 asserts no [Experimental] type appears in any PublicAPI.Shipped.txt.
It built its list of experimental types by harvesting class names from every
.cs file under src/ that contains the text "[Experimental(" - test projects
included. DiagnosticSuppressionConventionTests (added earlier in this series)
polices that attribute, so it contains the text five times, and it declares a
fixture class named AshlarSdkBuilder. The check therefore reported the real
AshlarSdkBuilder - which is [Obsolete], not [Experimental], and has been in
Ashlar.Sdk's PublicAPI.Shipped.txt since #499 - as an experimental type leaked
into the stable promise. Both UAT jobs went red on it. Nothing leaked.
- The harvest now skips any directory whose name contains "Tests".
- A floor: 82 experimental types are declared in product code today; fewer than
60 means the scan lost its lens, and "none leaked" from an empty list is a
pass that proves nothing, so it fails instead.
Observed, running only the 9.2 block against the tree:
- unfixed tree: FAIL "experimental types in the stable promise: AshlarSdkBuilder"
- the CI failure, reproduced;
- fixed tree: PASS, 82 types;
- a REAL leak (Ashlar.Core.Application.Autonomy.TouchSet appended to a Shipped
file): FAIL naming TouchSet;
- an empty scan (the file pattern changed to match nothing; anchor count 1 -> 0):
FAIL "the scan found only 0 [Experimental] types (floor 60)";
- restored: porcelain empty, PASS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hangelog The release-commit steps of docs/RELEASE_RUNBOOK.md "Before you tag" that the 0.2.0 cut (#678) could not do, because the work they cover landed after it. - Step 7, promote the public API. src/Ashlar.Abstractions/PublicAPI.Unshipped.txt carried six lines - the Ashlar.Abstractions.Paths.PathContainment helper added by the path-containment fix in this series. They move to PublicAPI.Shipped.txt (399 -> 405 lines), so every Unshipped.txt is back at the bare header and the next release's Unshipped diff shows only what is new after 0.2.0. docs/SdkCompatibilityPolicy.md said those six were "promised at the next tag"; it now records that this commit promoted them. - CHANGELOG: [Unreleased] - two waves of work since the cut (9 Changed, 2 Fixed, 4 Security) - folds into [0.2.0], dated 2026-10-01, the day it is tagged. [Unreleased] is reset to "Nothing yet.". - One statement in [0.2.0] became false by that fold: the Ashlar.Analyzers.Tests entry said the readiness routing gap was "deliberately left open here". The closure fix in this same release closed it. 0.2.0 is not published, so its notes are not yet history; the sentence now points at the closing entry instead. - consumer-template/CONSUMING.md dated the 0.2.0 pin 2026-09-29, the cut date; it now carries the release date. Steps 4-6 (version files, CONSUMING.md's trust rows, published-version docs) were done on the cut and are unchanged. Step 8 (rc rehearsal) and step 9 (readiness verdict) run against THIS commit after it reaches master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IanFrelinger
force-pushed
the
integrate/wave-3
branch
from
October 1, 2026 02:29
ca02d28 to
edc62fc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eight commits, meant to be rebase-merged so each lands on
masteron its own, and so that thetip is the 0.2.0 release commit. After merge: master's push readiness run, then
0.2.0-rc2from thatcommit (runbook step 8), then the readiness verdict for it (step 9), then the
v0.2.0tag.Five fixes were built in parallel, each in its own worktree. Each went implement → adversarial verifier →
repair → final verifier that could block only on an unaddressed finding, a fail-open defect or a
day-one red. All five ended LAND with nothing blocking; every first verifier found 2–4 must-fix
items, and every one was closed or declined with evidence. Every behavioural change was mutation-checked.
ci(runners)-latestlabel resolved to on 2026-09-30 (from each job's "Set up job" log), andverify-required-runner-pins.pyin requiredshell-lintrefuses a floating labelubuntu-latestbecomes Ubuntu 26.04 from 2026-10-19 (shellcheck 0.11, Python 3.14)fix(fleet-host)Ashlar__Api__EnableSwagger=true, as on Ashlar.APIfix(security)PathContainmenthelper;OutputPathSandboxedno longer accepts<root>-evilas inside<root>; a per-file ratchet forbids hand-rolled containmentAgentExecutorAdapterfix(api)PublicAPI.Shipped.txt; a convention bans public-API-tracking suppressions and requires every deprecation disable to name its symbolfix(trust)rag indexrequires--sensitivity; a lower re-label is refused; a filterless sanitizer blocks; a missing exfiltration policy derives from the agent's levelrag searchreturned the whole corpus; re-indexing silently downgradedtest(uat)[Experimental]types from product code only, with a floor of 60 (82 today)[Obsolete]AshlarSdkBuilderas a leaked experimental type; nothing leaked. Reproduced, fixed, and mutation-checked both ways (a real leak still fails; an empty scan fails the floor)docs(records)release:PathContainmentlines to Shipped),[Unreleased]folded into[0.2.0] - 2026-10-01, one contradiction in 0.2.0's notes correctedUser-visible changes (fail-closed)
rag searchwithout--max-sensitivitysearches at Public (it returned everything); the output states the clearance applied.rag indexrefuses without--sensitivity(it stored Public); re-indexing at a lower label is refused — remove first.ashlar improvewith trust enabled now actually blocks/redacts PII (the proxy had no filter).ExfiltrationPolicysection gets the restrictions its level implies. Disclosed loosening: for such an agent, the derivedMaxAllowedLevelrises from an accidentalPublicdefault to itsMaxDataSensitivity, which bounds machine-spawned children — matching the validator's own fallback.Verification of the combined tree
run-repo-gates.sh23/23; knowledge graph current (4,495 facts).ci/cert-gate-assertions.md(both kept).[skip-prod-style] - rationale
The testing-strategy gate flags this PR because two files under
src/Ashlar.Hosting/changed. Neither changes production wiring:Meai/MeaiVectorDataRagAdapter.cs- the fail-closed default clearance (an omitted clearance now means the floor). That is adapter behaviour; its DI registration in the kernel phases is unchanged, and its own tests were updated in this PR.Sdk/Builders/HostAshlarSdkBuilder.cs- one dead#pragma warning disable CS0618line deleted. No behaviour change.Measured rather than argued: on this tree's readiness run (36796629589) the ProdStyle and virtual-host suites all ran and passed on Linux, macOS and Windows - ProdStyle 39, KernelPhaseResolution 27, HostingE2ESmoke 30, HostingDeploymentProfile 81, and the changed adapter's tests 9; 0 failed. The DI-level change this series does make (
ashlar improveregistering the trust services) is pinned by the newImproveCommandTrustWiringTests.Known residue, not in this PR
bg proposals applywritesPath.Combine(repoRoot, p.TargetPath)with no containment check (a task chip was raised). Fleet.Host's/api/support/diagnostics500s with a valid key. Swagger still turns on underASPNETCORE_ENVIRONMENT=Testingin all three images — now documented. Two pinned follow-ups in the containment ratchet (AshlarEndpoints.cs,ProjectVerifier.cs).🤖 Generated with Claude Code