diff --git a/CHANGELOG.md b/CHANGELOG.md index 22dacdf4e..f4c7a7fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ the version on nuget.org, which is why it trails `VERSION` between releases rath ### Added +- **SPEC-007 PR 4.5: subject producers, still report-only.** The self-extend runner enters `agent:` at + `SystemHigh` around the cycle and the post-cycle admission and auto-share. Egress records for that cycle go from + `no-subject` to `subject:agent:` at `SystemHigh`. Nothing refuses, and no send changes. `ToolCallingAgent` + begins a read around each tool call. Only a tool that implements `IEgressLabelledTool` may report; `RAGTool` is + the only one, and it reports each hit's canonical label, or `Public` when the search read nothing. Any other tool, + and a tool that throws, counts as `SystemHigh`. A `peer:` chat response is observed as `SystemHigh` after it + returns. A model response is not a read. An open read still counts only when the scope ends, which is a known + limit when a runner's floor is below `SystemHigh` (the owner can reverse it). The labelled-tool marker is recorded + in `PublicAPI.Unshipped.txt`. + - **`Ashlar.Abstractions.Security`: Bell-LaPadula security labels and reference-monitor decisions.** `SecurityLevel` orders `Public < Internal < Confidential < Secret < TopSecret`, with the names and values of the primitive `DataSensitivityLevels`. `SecurityLabel` adds a set of compartments and a diff --git a/ci/cert-gate-assertions.md b/ci/cert-gate-assertions.md index 26d4ca265..a1cc33d44 100644 --- a/ci/cert-gate-assertions.md +++ b/ci/cert-gate-assertions.md @@ -60,18 +60,19 @@ away, can see what they would be turning off. | **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. | | **Security labels form a lattice; the reference monitor refuses read-up and write-down.** `Ashlar.Abstractions.Security.SecurityLabel` is a level (`Public` < `Internal` < `Confidential` < `Secret` < `TopSecret`), a compartment set and a caveat set, plus a distinguished top, `SystemHigh`. `a <= b` iff `a`'s level is at most `b`'s and each of `a`'s token sets is a subset of `b`'s; `Join` is (higher level, union, union) and `Meet` is (lower level, intersection, intersection); `Public` is the bottom, only `SystemHigh` dominates `SystemHigh`, joining with it gives it, and `x.Meet(SystemHigh)` is `x`. The order and both operations are held to the lattice laws over generated labels that include `SystemHigh`: the order is reflexive, antisymmetric and transitive, `Join` and `Meet` are commutative, associative, idempotent and absorb each other, and `Dominates` agrees with both. The canonical text form (`Secret//C:ALPHA,BRAVO//K:NOWEB`, `Public//K:NOWEB`, `SystemHigh`) round-trips, and `TryParse` accepts only canonical text - exact-case level, no whitespace, no empty segment or token, `C` before `K`, tokens strictly ascending - and sets its out label to `SystemHigh` on every refusal, which is also what `ParseOrSystemHigh` returns; JSON (and TypeDescriptor) carries a label only as that canonical string, so `SystemHigh` cannot come back lower and anything else, `null` included, is refused. `SecurityLevel`'s names and values match the primitive `DataSensitivityLevels` (name and `SensitivityValue`). `ReferenceMonitor.CanRead(clearance, data)` allows iff `data <= clearance` and `CanWrite(current, destination)` iff `current <= destination`; a refusal reports the first of `SystemHighData`, `LevelTooLow`, `MissingCompartment` and `MissingCaveat`, with a `Detail` naming the offending level or tokens, and `default(AccessDecision)` is refused with `NoDecision`, so `Reason` is `None` exactly when allowed. `HighWaterMark` starts at its floor, only rises, joins atomically when `Observe` is raced from many threads, and judges a write against the join of everything it observed. | `SecurityLabelLatticeLawTests`, `SecurityLabelTextFormTests`, `SecurityLabelLevelParityTests`, `ReferenceMonitorDecisionTests`, `HighWaterMarkTests` | Four of the ways this goes wrong are one-token edits that still compile, and three of them fail open. `Dominates` testing superset rather than subset says a clearance lacking a compartment dominates data that carries it; `<` for `<=` on levels stops a label dominating anything at its own level, itself included; a `Join` that intersects caveats makes a high-water mark forget `NOWEB`, so data derived from a no-web source can be written somewhere the source could not go; swapping `CanWrite`'s operands turns no-write-down into no-write-up. A parser that tolerated a lowercase token or a space would mint a second spelling of one label, a parse failure that fell back to anything below `SystemHigh` would make unlabelled data readable, and an `AccessDecision` whose default read as allowed would turn an uninitialised field into a grant. The level parity exists because the two level vocabularies are meant to be one ordering, and a drift between them silently moves a level for anything that maps one onto the other. **Scope:** nothing in the runtime consults the monitor yet, so this pins the semantics a later caller inherits, not enforcement anywhere. These are classification-style controls inside the runtime, not an accredited cross-domain solution. A clearance or a write destination that cannot be parsed must fail closed the other way, to `Public`; that is the caller's decision, documented on `ParseOrSystemHigh` and not tested here. | -| **The existing sensitivity vocabularies map onto security labels without moving their order.** Two bridges, both one way and level only. `Ashlar.AI.Pipeline.Rag.TrustTierOrder.RecordLabel` and `CallerLabel` resolve a tier name through `TryRank`, so they accept exactly what `RecordRank` and `CallerRank` accept (trimmed, any case, the `top-secret` alias): a known tier becomes the bare label at the level both ranks give it and prints as the canonical tier name, and a blank or unknown name fails closed in the direction its rank function already does, a record to `SystemHigh` and a caller to `Public`, so no tier name - `SystemHigh` and canonical label text included - yields a `SystemHigh` clearance. `Ashlar.BackgroundAgents.DataSensitivity.DataSensitivityLabelBridge.ToDataLabel` and `TryToClearance` key an `IDataSensitivityLevel` on `SensitivityValue` alone, the only thing `DataSensitivityRegistry.CanAccess` compares: 0 to 4 is the `SecurityLevel` of that number whatever the level is named or displayed as. Out of range they fail closed per role: as data, above 4 is `SystemHigh` and below 0 is `Public` (raising a data label only narrows who may read it); as a clearance, above 4 is narrowed to a bare `TopSecret` and below 0 is refused, because no label lies below `Public`. No level at all is `SystemHigh` as data and `Public` as a clearance. Names resolve by role, as the legacy resolvers do: a data label name through `GetByName` (an unknown name gives no level, so `SystemHigh`), a clearance name through `ResolveClearance`, whose floor the bridge refuses when a custom level below `Public` is registered, so the subject is never wider than legacy (pinned). Every bridged label has no compartments and no caveats: the four level flags (`AllowsExternalLLM`, `AllowsWebSearch`, `RequiresLocalOnly`, `AllowsNetworkExports`) are not carried, two levels that differ only in their flags map to equal labels, and whether flags become caveats is for a later change. `ReferenceMonitor.CanRead` over bridged labels decides as the legacy rule does on every pair of known tiers and every pair of values in 0 to 4, and is never wider anywhere: as `TrustTierOrder.IsAllowed` in every cell of the full matrix of known spellings and unknown names but the divergence below, and as `CanAccess` on every pair of values in 0 to 4, over an edge grid and generated levels with random flags. Outside 0 to 4 it is deliberately narrower (a custom level above 4 cannot read data at its own level, and a clearance below 0 reads nothing), pinned by the edge grid and the out-of-range facts. Within range, the one intended divergence is pinned on both sides, and the tier matrices count its cells exactly, so it cannot widen, vanish or move: an unlabelled or unrecognised record, or unlabelled data, is `SystemHigh`, so a `TopSecret` clearance that legacy lets read it (`IsAllowed`, or `CanAccess` after `DataSensitivityFallbacks.ResolveLabel`) is refused with `SystemHighData`, and the label form of the re-index downgrade rule refuses re-labelling an unlabelled stored record at `TopSecret`, which ranks accept. It is not wired in. | `TrustTierLabelBridgeTests`, `DataSensitivityLabelBridgeTests` | Each wrong bridge here compiles and looks reasonable. One that parsed tier or level names as label text (`SecurityLabel.TryParse`, `ParseOrSystemHigh`) would make `secret`, `top-secret` and `" Secret "`, all valid tiers today, `SystemHigh`: as a record label that hides the record from callers who read it now, and as a clearance, through `ParseOrSystemHigh`, one that reads everything. One that mapped an out-of-range clearance to `SystemHigh` would read everything; one that fell back to `TopSecret` for unlabelled data, as `ResolveLabel` does when no custom level above `TopSecret` is registered, would make that data readable by a `TopSecret` clearance; one that mapped by name instead of `SensitivityValue` would let a custom level named `SystemHigh` read everything. The divergence pins exist so that the change which switches enforcement to the bridges flips the legacy-side assertions by name, on purpose, rather than discovering the difference in production. **Scope:** nothing in the runtime calls the bridges or the monitor yet - `IsAllowed`, `VectorDataRagService`'s search filter and re-index downgrade check, `CanAccess`, `RagSensitivity`, `DataSensitivityFallbacks` and the markers decide exactly as before - so this pins the mapping a later caller inherits, not enforcement anywhere. Nothing may derive a tier or level name back from a label's `Level`, because `SystemHigh` reports `TopSecret` there; that is documented on both bridges and not tested here. | -| **Every outbound path from production code is pinned in the egress inventory, a new one cannot appear unlisted, and the egress guard decides as specified - report-only, with every listed non-exempt site routed through it.** The guard (`Ashlar.Abstractions.Security.Egress`: `IEgressGuard`, `EgressGuard`, `EgressHttp`, `EgressDecisionLog`) exists and is report-only. `Evaluate` classifies the destination by a table derived from the built-in `DataSensitivityLevels` flags (loopback, `localhost`, `unix`, `npipe` and a declared `host:` name are the host boundary at `SystemHigh`, and since SPEC-007 PR 4.1 a `file` URI or a `file:` name, recorded as written, never is; a model family is `Internal`, web search `Confidential`, a network export `Internal`, anything else `Public`), decides `ReferenceMonitor.CanWrite(current, destination)`, where current is the join of the high-water marks of every `EgressSubject` frame on the flow's chain, live or disposed, up to a detachment (the flow's own frame and every frame it was entered inside; the frame-semantics rows below) or, with no live frame or under a detachment, `SystemHigh` (`no-subject`), and publishes the record to the `Ashlar-Egress` EventSource and to every `EgressDecisionLog` subscriber, except a decision made on a thread that is already publishing (an egress that a sink or listener itself causes), which is returned to its caller and counted but not published, so the pipeline cannot recurse. `AddAshlarEgressGuard` (`Ashlar.Infrastructure.Egress`) subscribes an `ILogger` sink at Debug under `Ashlar.Egress` and puts the guard handler on every `IHttpClientFactory` client through one `ConfigureHttpClientDefaults` call. The guard never throws and refuses nothing: a classifier fault is recorded with `Fault` and `NoDecision`, and a URI or URL-shaped destination is recorded as scheme, host and port only, never userinfo, path or query (a `file:` name is a path and, since PR 4.1, is recorded as written, as every other file site's path is). **The mode (SPEC-007 PR 4.6).** Every decision also records a `Mode` (`report` or `enforce`) from one resolver, `EgressEnforcement.ResolveMode(profile, override)`, the `ModeBasis` that decided it, `Refused` (`Mode` is `enforce` and `Access` does not allow the egress) and `Ref`, a random 64-bit reference in 16 hex digits, and event 1 appends `modeBasis`, `refused` and `ref` at version 1. Every profile still defaults to `report`; an unreadable override or profile and a fault while resolving the mode fail closed to `enforce`; no route acts on the mode until PR 4.7, so the guard still refuses nothing. `EgressModeResolutionTests` pins the 6 × 4 mode table and what an explicit guard records; `EgressModeProcessBindingTests` pins the two bindings: the override read once, the hosting option raise-only, a guard without a profile never lowering a process that enforces, the strictest profile noted winning for `ProcessDefault` and for the guard `AddAshlar` composes and its startup line (a later `AddAshlar` on the same collection replaces the guard an earlier one composed, so a stricter profile or a raised mode reaches that guard and its line together), one profile read per decision, the reset seam, and the probe staying on its flow. The twins execute it: the table equals the derivation; `no-subject` is refused with `SystemHighData` off the host and allowed on loopback; an `Internal` subject frame is allowed to an external model and restored on `Dispose` across `await`; `air-gapped` and `secure-workstation` set `ProfileEnforcesByDefault` and `full` does not; `EgressHttp.Wrap` records exactly one decision per send, returns the inner response instance and never reads the request content, and a guard fault does not change the response (on net8.0 and later); a subscriber that causes an egress does not recurse, and its nested decision is not published; `AddAshlarEgressGuard` called twice, with a named client over a stub handler, records exactly one decision with site `factory:x`. The netstandard2.0 asset, which .NET 5-7 apps resolve, cannot override a synchronous `Send`, so it refuses one (SPEC-007 PR 4.2): `EgressHttpNetstandard20TwinTests` loads that build of `Ashlar.Abstractions`, which the csproj builds and copies to the output, into an `AssemblyLoadContext` of its own and proves the harness binds it, then shows a synchronous `Send` through a client or a `Wrap` handler refused with `NotSupportedException` naming the hop before the inner handler sees it and with no decision, `SendAsync` still evaluated exactly once with the same request and response, no record published when a client or handler is built (no egress has happened then), the hop under the guard handler in every client shape with the caller's inner handler reachable through the hop's internal `Inner`, the inner handler still owned, and `CreateDelegatingHandler` refused with `PlatformNotSupportedException` after its argument checks; and, with that copy and its own `Ashlar-Egress` event source loaded, the guard's own source still writes every decision (.NET will not initialize a second event source of the same name, so the harness initializes the guard's first); at the base commit that `Send` returned the inner handler's response with no decision. SPEC-007 PR 4.3 stops automatic redirects on every primary Ashlar builds or binds (`AllowAutoRedirect` reads false on `HttpClientHandler` and `SocketsHttpHandler`) and follows them in an internal handler directly above the primary. `EgressRedirectTwinTests` shows an `EgressHttp` client (P2) follows a same-host redirect and records a second decision, returns a cross-host 3xx without requesting the second host, and in report mode does not send hop 2 to a different host; a factory client (P1) follows a same-host and a cross-host redirect and re-evaluates each hop; a handler between the guard and the primary that rewrites the request URI is evaluated for the rewritten host before the primary sends; `ConfigureAdditionalHttpMessageHandlers` that `Clear()`s still has the guard handler and exactly one decision; an unknown primary is not flipped, a followed authority is evaluated after the send with a warning that the body may already have gone, and building one logs a warning naming the type; a started handler whose setter throws does not throw out of the bind; and the follower matches `SocketsHttpHandler` on 301, 302, 303, 307 and 308, a relative or missing `Location`, the fragment, the redirect limit, an HTTPS-to-HTTP refusal and clearing `Authorization`. On the netstandard2.0 asset the hop stays `SynchronousSendRefusedOnNetstandard20Asset` outside the redirect handler, so a synchronous `Send` still throws before the follower runs. The route twins run the routed sites themselves: a provider composed by `AddAshlarProfile` (which calls `AddAshlar`) records one `factory:` decision per send on its default client, and every kernel member that installs the guard still leaves one handler per client (`EgressKernelFactoryTwinTests`); every keyed MEAI target's outermost client is `EgressGuardChatClient`, which records one decision per call with the destination the target's inner client names (its `ChatClientMetadata.ProviderUri`, else the fail-closed `meai:`; only the in-process LLamaSharp client under `local:onnx` records none, and an Ollama model ending in `-cloud` or `:cloud` is recorded at `https://ollama.com`; a Bedrock key keeps site `EG-MDL-02` under a reported URI, and the AWS Bedrock client reports none, so its record is the region's endpoint), the default Ollama client does not follow a redirect, before PolicyGate's synchronous deny, and never lets a throwing host guard reach the caller (`EgressGuardChatClientTwinTests`); `ProviderFactory`'s static and Ollama clients and a gRPC channel record their sites, and `OllamaProvider` records an Ollama cloud model at `https://ollama.com`, a short name that resolves to one included (`EgressRawClientTwinTests`); and the explicit guards record their site, family and destination, among them `ProcessCommandRunner`'s docker host or context, Sneakernet's `.nxpkg` path, the shared-adaptation broadcast (both record a path spelled `//127.0.0.1/…` as written, by family, never as Host), the dotnet restore rule and Bing's endpoint (`EgressExplicitSiteTwinTests`). **3b routes every listed non-exempt site.** `ci/egress-inventory.tsv` pins every outbound site the scan sees: 86 (path, marker) rows with total and guarded counts and the `EG-` ids of `docs/EgressInventory.md`, 150 occurrences of which 48 are guarded (PR 4.2 adds the netstandard2.0 hop's `HttpMessageInvoker`, `Exempt:GuardImpl`). 33 rows have nothing unguarded (`-`); 35 carry a final reason from a closed `Exempt:` set (`Exempt:GuardImpl` only under `src/Ashlar.Abstractions/Security/Egress/`, `Exempt:ConsumerSdk` only under `src/Ashlar.Client/`); 10 are `Factory`, 4 `Upstream:` and 4 `Governance`. The 3a reason `Unrouted` is gone, and F3 rejects it. The population is every `.cs` under src, application, applications, commercial, products, tools and consumer-template, minus bin, obj, dot-directories, nested clones and test projects identified by their csproj content (never by name), with `spikes/` and `samples/` excluded by name, and with comments and literal contents blanked. The markers are `http.new`, `http.param`, `http.register`, `sdk.client`, `socket`, `process`, `door`, `telemetry`, `store`, `chat.register` and `banned`. The pins hold in both directions: a count above its pin or an unlisted pair fails, and so does a row that no longer matches exactly. Routes hold. A Wrapped count is met by the construction rule. A Precedes count is met by the guard-call rule, and for an http.param by the stored-client rule: in a type that is not partial, and never in a record's primary constructor (whose parameter is also a public property), the parameter is only named, null-checked, configured, stored into a private field or property, or sent after a guard; every other mention of that holder is a configuration or guard-preceded; and at least one send is guard-preceded. A Factory http.register is met by D1: a non-declaration `AddAshlarEgressGuard(` after the registration, as a statement of its own innermost code block (the file root of a top-level program counts), never before it or in an enclosing or sibling block. An http.param `Factory` holds by the call proof, where every supply site passes, in the parameter's position, a client made by an `IHttpClientFactory`-typed receiver declared in that file (directly, through a never-reassigned local, through a same-file private factory method, or through the caller's own parameter proven the same way; a deconstruction into the local or parameter, a tuple swap included, counts as a reassignment), or by the typed proof, where the type is a typed client of a Factory-classified `AddHttpClient<…, T>` and nothing else constructs it. `Upstream:` holds for an http.param when the named file is its only supplier, builds no unguarded client, exposes no client field, property, indexer or explicit interface property, is not partial and is itself grounded (a Wrapped http.new or another routed http.param row), and for an http.register when the row's project cannot reach `Ashlar.Infrastructure` and every executable that composes it calls `AddAshlarEgressGuard` in its program, the named one included. `Governance` holds only for the four MEAI pairs, and only while F5 is green. An Exempt reason is in the closed set. Factory and Upstream reason about a closed world: every supply site of a receiving member is a production file in this scan, found by name, an auto-property initializer `T P { … } = new(…)` included; a positional record's primary constructor fails both routes, since `with` or an object initializer can replace its public property; a spelling the scan cannot follow (`CreateInstance`, `typeof(T)`, an empty-argument generic registration, a method group) fails the route instead of being assumed away; the receiving type must not be partial and, for a constructor, must be sealed; and a cycle fails. A raw construction is Wrapped only inside an `EgressHttp.Wrap(` or `EgressHttp.CreateClient(` argument region, or when a local declaration stores it and every later mention of that local, to the end of its block, is inside such an argument region or configures it (`name.Prop = …`), with at least one inside a wrap; a field, or a local returned, passed elsewhere or reassigned, stays unguarded. `ConfigureHttpClientDefaults(` occurs exactly once in production, in `EgressServiceCollectionExtensions.cs`, around `EgressHttp.CreateDelegatingHandler(`, and `Add…`, `typeof(HttpClient)` and `Get[Required][Keyed]Service[s]` occur nowhere. F4 also holds (A) every http.register in AddAshlar's own file is Factory; (B) `AddAshlarEgressGuard(` is declared exactly once in production, in `EgressServiceCollectionExtensions.cs`; (D) no production type implements `IHttpClientFactory` and nothing registers it directly (`Add…`, `typeof(IHttpClientFactory)`, `ServiceDescriptor.{Singleton|Scoped|Transient}`); and (E) every call of `AddAshlarEgressGuard(` covers at least one registration under D1, so a call in the wrong place fails. Chat and embedding registrations occur only in `MeaiPipelineServiceCollectionExtensions.cs`; its four `AddKeyedChatClient` statements each carry `.UseAshlarGovernance(` with the same key, compared on the raw text because cleaning blanks literals; the leaf chat clients are built only inside them or in a local that only they use, and the Bedrock client only in `AwsBedrockChatClientFactory.cs`; the four leaf types (`OllamaHttpChatClient`, `LlamaSharpChatClient`, `AwsBedrockChatClientFactory`, `IBedrockChatClientFactory`) occur only in their own files and that registration file; no raw `IChatClient` is registered (`Add…`, `typeof(IChatClient)`, `new ServiceDescriptor(typeof(IChatClient), …)`); and governance composes EgressGuard, then PolicyGate, then Sanitizing, then Auditing, with `new EgressGuardChatClient(` in no other production file. Every TSV id is a row of `docs/EgressInventory.md`, every `EG-` literal in production code is a row id there, and every row there whose route is not `Unscanned:*` is named by a TSV row; a docs file from which no `EG-` row parses fails rather than checking against nothing. Stated floors (1000 scanned files, 60 examined occurrences and 30 parsed docs rows; measured 2,144, 150 and 67 with PR 4.3's two files on top of PR 4.10, after 2,142, 150 and 67 at PR 4.10, 2,135, 150 and 67 at PR 4.4, 2,134, 150 and 67 at PR 4.2, 2,133, 149 and 67 at PR 4.6, 2,130, 148 and 67 at 3b and 2,128, 146 and 67 at 3a; 4.1 adds one occurrence, 4.6 three files, 4.2 one file and one occurrence, 4.4 one file, 4.10 seven files, and 4.3 two files (the redirect handler and the factory binding filter; neither constructs an HTTP client, so the occurrence count stays 150)), the six guard files, named anchor files per marker and the reach of the scan (`RemoteExecutionPlatform.cs`, `Ashlar.Agents.TestKit`, the CLI and `commercial/src/` in; four test projects out) stop an emptied scan passing. 113 named classification controls (72 at 3a) drive the classifier on real shapes, and 72 route controls build small fixture trees whose route must hold or fail with a named clause; the known misses they pin hold what the scan reports today, so the change that teaches the scan must flip them. The class runs 194 tests: 9 facts, 113 classification controls and 72 route controls. A failure prints the observed rows in TSV form, so a pin is updated mechanically. | `EgressGuardConventionTests`, `EgressGuardDecisionTests`, `EgressModeResolutionTests`, `EgressModeProcessBindingTests`, `EgressHttpHandlerTwinTests`, `EgressHttpNetstandard20TwinTests`, `EgressRedirectTwinTests`, `EgressFactoryDefaultsTwinTests`, `EgressKernelFactoryTwinTests`, `EgressGuardChatClientTwinTests`, `EgressRawClientTwinTests`, `EgressExplicitSiteTwinTests` | An outbound path arrives and nothing records it. The inventory found three that no gate saw: `ProviderFactory`'s static `new()` client, which sends prompts to OpenAI outside `UseAshlarGovernance`, under AirGapped too whenever `OPENAI_API_KEY` is set; the `IdeEndpoints.cs` fallback `new HttpClient()`; and the MCP `HttpClientTransport` built with two arguments, so the SDK builds a client of its own. 3b builds all three with `EgressHttp`. Without the pins, a routed site could go back to a raw client and stay green, and the next one would be added unlisted. Without the twins, the guard could stop deciding, start reading request bodies or throw into a caller while every static fact stayed green; without the netstandard2.0 twin, the asset .NET 5-7 apps resolve would run in no required check, and a synchronous `Send` through it could go out unevaluated again while every net8.0 twin stayed green; without the route twins, a route could hold on paper while the client it names records nothing. **A tripwire, not a proof, and report-only:** the scan is textual. It does not see HTTP clients built inside third-party SDKs, inbound server responses (`Unscanned:Inbound`), file egress through no door method, aliases, interpolation holes or reflection, or an executable chosen by a caller of a process funnel. Its guard-call rule is textual order, not temporal, and it reads braces only: a guard with no braces of its own counts for its enclosing block, so a guard in an unbraced `if` or loop body, in another `switch` case, or in an expression-bodied lambda or local function counts though it may never run. A new network SDK is caught only once its constructor is taught. The routes add their own blind spots: an Upstream supplier passing a static client defined in another file; a guard installed on a different `IServiceCollection` from the one the registration fills; a host outside the repository that composes a factory consumer with no Ashlar member that installs the guard (`AddAshlarFederatedBrickMesh` alone); reflection and target-typed `new(…)` suppliers in an argument, a `return` or an expression-bodied `T P => new(…)`, and a bare call through `using static` from another file; an explicit interface method that returns the client from an Upstream file; an `IHttpClientFactory` or raw `IChatClient` registration whose service type the text does not name; an early return between a registration and its guard, and an install call with no braces of its own; an `IHttpClientFactory`-typed receiver backed by a custom implementation, which only F4 (D) catches; an Upstream file that hands its client out through a non-private method or a `Func`, `Lazy`, array or tuple member, and a guarded holder mention that copies a stored client into another member sent unguarded elsewhere (both pinned as known misses); and every factory client records the family `http.factory`, the model calls included. So this proves the inventory is complete for the taught shapes, that each pinned route holds as the scan reads it, and that the guard decides as specified; it does not prove that any path is mediated. Every listed non-exempt site now reaches a decision and the guard refuses nothing; enforcement is PR 4. These are classification-style controls inside the runtime, not an accredited cross-domain solution. | -| **A nested egress subject never decides below a frame it was entered inside.** A decision's current label is the join of the high-water marks of every `EgressSubject` frame on the flow's chain, live or disposed, up to a detachment (the flow's own frame and every frame it was entered inside), read when the decision is made, with the innermost live frame's `subject:` basis, so a `Public` frame entered inside a `Secret` one decides `Secret`. Disposing a frame observes its mark into every frame it was entered inside, also from a child task whose frames never flow back out. `EgressSubject.Observe` joins a label into every frame on the chain at once, so an enclosing flow that egresses while a nested frame is still running already counts it; it only raises, and with no frame it does nothing. A frame disposed out of order still counts for the frame entered inside it (`Secret`, `LevelTooLow`), and a fire-and-forget task started inside a `Secret` frame still decides `Secret` after that frame's `using` ends, in the frame it had entered and in one it enters later, and with no frame of its own at what the frame's mark rose to after it ended, while the flow that ended it in order decides `Public`; what a sibling task reads after the parent ended, in a frame of its own or with none, still reaches the parent and so the sibling. A frame being disposed counts as live until its mark has reached the frames it was entered inside, so a task with no frame of its own inside it never decides below that mark, nor names the enclosing frame before it holds the mark. A flow leaves a frame only by disposing its own head while that head is undisposed, and goes back to exactly the frame that one was entered under, disposed or not; disposing a frame anywhere else moves no flow, and a flow whose head was already disposed never leaves it. So a flow that disposes its own frames out of order stays inside the outer frame it disposed (fail closed): a frame it enters afterwards joins that frame's mark (`Secret`), in every order of three (the six orders leave 13 frames on one flow, as a model of the rule says after every step), an async loop grows the flow's chain by exactly one frame per iteration (50 iterations, every length pinned), an enclosing live frame keeps the disposed frame's mark, and nothing stays reachable from the inner frame; and what such a flow reads later, with no frame of its own or in a frame it enters and ends in order, raises the outer frame's mark, which is the subject's shared one, so another session of that subject decides at it (`Confidential`, `LevelTooLow`). A task started inside a frame disposed first, also one started after an out-of-order dispose or handed a child scope to end, and the flow that entered a frame another flow disposed (a background task, `await Task.Run(parent.Dispose)`) stay inside it, in every dispose order and also after ending the frames around it, and read its mark when they decide, with no frame of their own or in one they enter, also what it rose to after it ended, by a direct raise or through the same mark entered in another frame (`Secret`). `EgressGuardDecisionTests.An_Internal_subject_may_reach_an_external_model_across_await_and_Dispose_restores_the_previous_frame` was flipped by name: its inner block pinned "inner Public decides Public". | `EgressSubjectNestingTests`, `EgressGuardDecisionTests` | Before SPEC-007 PR 4.4 the guard read only the innermost live frame, so any code that entered a fresh frame declassified everything its subject had read, and a nested subject's reads were lost when its frame ended. Once the guard enforces, either one is a write-down with no downgrade. A parent frame that ends first would otherwise declassify a child task still running inside it, whose closures can still hold the parent's data. If a flow could leave a frame other than by disposing its own head, some flow that never left a frame could decide below it. Staying inside fails closed, at two costs recorded as known limits in the `EgressSubject` remarks: a flow that disposes its frames out of order keeps the outer frame, so its chain grows with every repetition, which every later decision on it walks (availability), and what it reads later raises that subject's shared mark (another session's label). Neither arises on the flow that entered a frame and disposed it in a `using` block, in order, which every production `Enter` must be (PR 4.5); work created and started inside that frame, such as a task or a thread, still keeps it for as long as it runs, so what it reads later still raises the frame's shared mark. Work keeps the frames of the flow where it captured the execution context: a `Task`, a `System.Threading.Timer`, a registration or a continuation where it is created, a `Thread` or a `System.Timers.Timer` where it is started. So what is read by a `Task`, `System.Threading.Timer`, registration or continuation created before the frame and started or triggered inside it, such as a cold task, or by a thread created inside it and started after the `using` ends, never reaches the frame's mark, and a decision in the frame can be below it: a producer creates and starts the work it reads through inside its `using` block. Such an `Enter` is never inside an async helper, whose frame does not reach the flow that awaits it, and never across a `yield return` of an async iterator, whose body resumes on its consumer's flow, so the rest of the block would decide at the consumer's frames and its reads would never reach the frame's mark. If a flow still inside a disposed frame read only the mark that frame had when it ended, a later read by the same subject, under the same mark elsewhere, would not reach it. Report-only until the switch (4.11), and no production code enters a frame yet (4.5). | -| **A read counts at what it reported only when it returned and reported; otherwise it counts as `SystemHigh`.** `EgressSubject.BeginRead()` returns a `ReadScope`, which observes into every frame of the chain it was begun on when it is disposed: after `Complete()` with no `Report`, `SystemHigh`; after `Complete()` with reports, their join and nothing else (a `Public` report reads nothing and raises no mark); without `Complete()`, as when the read throws, `SystemHigh` even after a report. `EgressSubject.Observe` never satisfies a scope. The scope observes into the chain it was begun on from whatever flow it ends on, including one that never had that chain (`ExecutionContext.SuppressFlow`, a thread started without the execution context, or a callback run detached); an unreported read begun in a task with no frame of its own after its parent frame ended still raises that parent, so a sibling task inside it decides `SystemHigh`; a report after it ended still reaches the frames from any flow, concurrent reports all count, and with no frame a scope changes nothing. | `EgressSubjectReadScopeTests` | Without the unreported-read rule, an unlabelled tool result counts as nothing read and the agent's next model call carries it at the floor. Without the completion rule, a tool that reads and then throws sends its data out in the exception's message at whatever it reported first. If `Observe` satisfied a scope, any tool could launder its result by observing `Public` on a side value. No production code begins a read yet: `ToolCallingAgent` and `RAGTool` are wired in PR 4.5. | +| **The existing sensitivity vocabularies map onto security labels without moving their order.** Two bridges, both one way and level only. `Ashlar.AI.Pipeline.Rag.TrustTierOrder.RecordLabel` and `CallerLabel` resolve a tier name through `TryRank`, so they accept exactly what `RecordRank` and `CallerRank` accept (trimmed, any case, the `top-secret` alias): a known tier becomes the bare label at the level both ranks give it and prints as the canonical tier name, and a blank or unknown name fails closed in the direction its rank function already does, a record to `SystemHigh` and a caller to `Public`, so no tier name - `SystemHigh` and canonical label text included - yields a `SystemHigh` clearance. `Ashlar.BackgroundAgents.DataSensitivity.DataSensitivityLabelBridge.ToDataLabel` and `TryToClearance` key an `IDataSensitivityLevel` on `SensitivityValue` alone, the only thing `DataSensitivityRegistry.CanAccess` compares: 0 to 4 is the `SecurityLevel` of that number whatever the level is named or displayed as. Out of range they fail closed per role: as data, above 4 is `SystemHigh` and below 0 is `Public` (raising a data label only narrows who may read it); as a clearance, above 4 is narrowed to a bare `TopSecret` and below 0 is refused, because no label lies below `Public`. No level at all is `SystemHigh` as data and `Public` as a clearance. Names resolve by role, as the legacy resolvers do: a data label name through `GetByName` (an unknown name gives no level, so `SystemHigh`), a clearance name through `ResolveClearance`, whose floor the bridge refuses when a custom level below `Public` is registered, so the subject is never wider than legacy (pinned). Every bridged label has no compartments and no caveats: the four level flags (`AllowsExternalLLM`, `AllowsWebSearch`, `RequiresLocalOnly`, `AllowsNetworkExports`) are not carried, two levels that differ only in their flags map to equal labels, and whether flags become caveats is for a later change. `ReferenceMonitor.CanRead` over bridged labels decides as the legacy rule does on every pair of known tiers and every pair of values in 0 to 4, and is never wider anywhere: as `TrustTierOrder.IsAllowed` in every cell of the full matrix of known spellings and unknown names but the divergence below, and as `CanAccess` on every pair of values in 0 to 4, over an edge grid and generated levels with random flags. Outside 0 to 4 it is deliberately narrower (a custom level above 4 cannot read data at its own level, and a clearance below 0 reads nothing), pinned by the edge grid and the out-of-range facts. Within range, the one intended divergence is pinned on both sides, and the tier matrices count its cells exactly, so it cannot widen, vanish or move: an unlabelled or unrecognised record, or unlabelled data, is `SystemHigh`, so a `TopSecret` clearance that legacy lets read it (`IsAllowed`, or `CanAccess` after `DataSensitivityFallbacks.ResolveLabel`) is refused with `SystemHighData`, and the label form of the re-index downgrade rule refuses re-labelling an unlabelled stored record at `TopSecret`, which ranks accept. `RAGTool` calls `ToDataLabel` for a canonical hit (PR 4.5); `TryToClearance` and `TrustTierOrder.RecordLabel` are still not on a production decision path except the parity the producer fact pins. | `TrustTierLabelBridgeTests`, `DataSensitivityLabelBridgeTests`, `EgressSubjectProducerTests` | Each wrong bridge here compiles and looks reasonable. One that parsed tier or level names as label text (`SecurityLabel.TryParse`, `ParseOrSystemHigh`) would make `secret`, `top-secret` and `" Secret "`, all valid tiers today, `SystemHigh`: as a record label that hides the record from callers who read it now, and as a clearance, through `ParseOrSystemHigh`, one that reads everything. One that mapped an out-of-range clearance to `SystemHigh` would read everything; one that fell back to `TopSecret` for unlabelled data, as `ResolveLabel` does when no custom level above `TopSecret` is registered, would make that data readable by a `TopSecret` clearance; one that mapped by name instead of `SensitivityValue` would let a custom level named `SystemHigh` read everything. The divergence pins exist so that the change which switches enforcement to the bridges flips the legacy-side assertions by name, on purpose, rather than discovering the difference in production. **Scope:** `RAGTool.MapHitLabel` calls `ToDataLabel` for the egress read report (PR 4.5), pinned against `TrustTierOrder.RecordLabel`. `IsAllowed`, `VectorDataRagService`'s search filter and re-index downgrade check, `CanAccess`, `RagSensitivity`, `DataSensitivityFallbacks` and the markers decide exactly as before, so this pins the mapping a later caller inherits, not enforcement anywhere. Nothing may derive a tier or level name back from a label's `Level`, because `SystemHigh` reports `TopSecret` there; that is documented on both bridges and not tested here. | +| **Every outbound path from production code is pinned in the egress inventory, a new one cannot appear unlisted, and the egress guard decides as specified - report-only, with every listed non-exempt site routed through it.** The guard (`Ashlar.Abstractions.Security.Egress`: `IEgressGuard`, `EgressGuard`, `EgressHttp`, `EgressDecisionLog`) exists and is report-only. `Evaluate` classifies the destination by a table derived from the built-in `DataSensitivityLevels` flags (loopback, `localhost`, `unix`, `npipe` and a declared `host:` name are the host boundary at `SystemHigh`, and since SPEC-007 PR 4.1 a `file` URI or a `file:` name, recorded as written, never is; a model family is `Internal`, web search `Confidential`, a network export `Internal`, anything else `Public`), decides `ReferenceMonitor.CanWrite(current, destination)`, where current is the join of the high-water marks of every `EgressSubject` frame on the flow's chain, live or disposed, up to a detachment (the flow's own frame and every frame it was entered inside; the frame-semantics rows below) or, with no live frame or under a detachment, `SystemHigh` (`no-subject`), and publishes the record to the `Ashlar-Egress` EventSource and to every `EgressDecisionLog` subscriber, except a decision made on a thread that is already publishing (an egress that a sink or listener itself causes), which is returned to its caller and counted but not published, so the pipeline cannot recurse. `AddAshlarEgressGuard` (`Ashlar.Infrastructure.Egress`) subscribes an `ILogger` sink at Debug under `Ashlar.Egress` and puts the guard handler on every `IHttpClientFactory` client through one `ConfigureHttpClientDefaults` call. The guard never throws and refuses nothing: a classifier fault is recorded with `Fault` and `NoDecision`, and a URI or URL-shaped destination is recorded as scheme, host and port only, never userinfo, path or query (a `file:` name is a path and, since PR 4.1, is recorded as written, as every other file site's path is). **The mode (SPEC-007 PR 4.6).** Every decision also records a `Mode` (`report` or `enforce`) from one resolver, `EgressEnforcement.ResolveMode(profile, override)`, the `ModeBasis` that decided it, `Refused` (`Mode` is `enforce` and `Access` does not allow the egress) and `Ref`, a random 64-bit reference in 16 hex digits, and event 1 appends `modeBasis`, `refused` and `ref` at version 1. Every profile still defaults to `report`; an unreadable override or profile and a fault while resolving the mode fail closed to `enforce`; no route acts on the mode until PR 4.7, so the guard still refuses nothing. `EgressModeResolutionTests` pins the 6 × 4 mode table and what an explicit guard records; `EgressModeProcessBindingTests` pins the two bindings: the override read once, the hosting option raise-only, a guard without a profile never lowering a process that enforces, the strictest profile noted winning for `ProcessDefault` and for the guard `AddAshlar` composes and its startup line (a later `AddAshlar` on the same collection replaces the guard an earlier one composed, so a stricter profile or a raised mode reaches that guard and its line together), one profile read per decision, the reset seam, and the probe staying on its flow. The twins execute it: the table equals the derivation; `no-subject` is refused with `SystemHighData` off the host and allowed on loopback; an `Internal` subject frame is allowed to an external model and restored on `Dispose` across `await`; `air-gapped` and `secure-workstation` set `ProfileEnforcesByDefault` and `full` does not; `EgressHttp.Wrap` records exactly one decision per send, returns the inner response instance and never reads the request content, and a guard fault does not change the response (on net8.0 and later); a subscriber that causes an egress does not recurse, and its nested decision is not published; `AddAshlarEgressGuard` called twice, with a named client over a stub handler, records exactly one decision with site `factory:x`. The netstandard2.0 asset, which .NET 5-7 apps resolve, cannot override a synchronous `Send`, so it refuses one (SPEC-007 PR 4.2): `EgressHttpNetstandard20TwinTests` loads that build of `Ashlar.Abstractions`, which the csproj builds and copies to the output, into an `AssemblyLoadContext` of its own and proves the harness binds it, then shows a synchronous `Send` through a client or a `Wrap` handler refused with `NotSupportedException` naming the hop before the inner handler sees it and with no decision, `SendAsync` still evaluated exactly once with the same request and response, no record published when a client or handler is built (no egress has happened then), the hop under the guard handler in every client shape with the caller's inner handler reachable through the hop's internal `Inner`, the inner handler still owned, and `CreateDelegatingHandler` refused with `PlatformNotSupportedException` after its argument checks; and, with that copy and its own `Ashlar-Egress` event source loaded, the guard's own source still writes every decision (.NET will not initialize a second event source of the same name, so the harness initializes the guard's first); at the base commit that `Send` returned the inner handler's response with no decision. SPEC-007 PR 4.3 stops automatic redirects on every primary Ashlar builds or binds (`AllowAutoRedirect` reads false on `HttpClientHandler` and `SocketsHttpHandler`) and follows them in an internal handler directly above the primary. `EgressRedirectTwinTests` shows an `EgressHttp` client (P2) follows a same-host redirect and records a second decision, returns a cross-host 3xx without requesting the second host, and in report mode does not send hop 2 to a different host; a factory client (P1) follows a same-host and a cross-host redirect and re-evaluates each hop; a handler between the guard and the primary that rewrites the request URI is evaluated for the rewritten host before the primary sends; `ConfigureAdditionalHttpMessageHandlers` that `Clear()`s still has the guard handler and exactly one decision; an unknown primary is not flipped, a followed authority is evaluated after the send with a warning that the body may already have gone, and building one logs a warning naming the type; a started handler whose setter throws does not throw out of the bind; and the follower matches `SocketsHttpHandler` on 301, 302, 303, 307 and 308, a relative or missing `Location`, the fragment, the redirect limit, an HTTPS-to-HTTP refusal and clearing `Authorization`. On the netstandard2.0 asset the hop stays `SynchronousSendRefusedOnNetstandard20Asset` outside the redirect handler, so a synchronous `Send` still throws before the follower runs. The route twins run the routed sites themselves: a provider composed by `AddAshlarProfile` (which calls `AddAshlar`) records one `factory:` decision per send on its default client, and every kernel member that installs the guard still leaves one handler per client (`EgressKernelFactoryTwinTests`); every keyed MEAI target's outermost client is `EgressGuardChatClient`, which records one decision per call with the destination the target's inner client names (its `ChatClientMetadata.ProviderUri`, else the fail-closed `meai:`; only the in-process LLamaSharp client under `local:onnx` records none, and an Ollama model ending in `-cloud` or `:cloud` is recorded at `https://ollama.com`; a Bedrock key keeps site `EG-MDL-02` under a reported URI, and the AWS Bedrock client reports none, so its record is the region's endpoint), the default Ollama client does not follow a redirect, before PolicyGate's synchronous deny, and never lets a throwing host guard reach the caller (`EgressGuardChatClientTwinTests`); `ProviderFactory`'s static and Ollama clients and a gRPC channel record their sites, and `OllamaProvider` records an Ollama cloud model at `https://ollama.com`, a short name that resolves to one included (`EgressRawClientTwinTests`); and the explicit guards record their site, family and destination, among them `ProcessCommandRunner`'s docker host or context, Sneakernet's `.nxpkg` path, the shared-adaptation broadcast (both record a path spelled `//127.0.0.1/…` as written, by family, never as Host), the dotnet restore rule and Bing's endpoint (`EgressExplicitSiteTwinTests`). **3b routes every listed non-exempt site.** `ci/egress-inventory.tsv` pins every outbound site the scan sees: 86 (path, marker) rows with total and guarded counts and the `EG-` ids of `docs/EgressInventory.md`, 150 occurrences of which 48 are guarded (PR 4.2 adds the netstandard2.0 hop's `HttpMessageInvoker`, `Exempt:GuardImpl`). 33 rows have nothing unguarded (`-`); 35 carry a final reason from a closed `Exempt:` set (`Exempt:GuardImpl` only under `src/Ashlar.Abstractions/Security/Egress/`, `Exempt:ConsumerSdk` only under `src/Ashlar.Client/`); 10 are `Factory`, 4 `Upstream:` and 4 `Governance`. The 3a reason `Unrouted` is gone, and F3 rejects it. The population is every `.cs` under src, application, applications, commercial, products, tools and consumer-template, minus bin, obj, dot-directories, nested clones and test projects identified by their csproj content (never by name), with `spikes/` and `samples/` excluded by name, and with comments and literal contents blanked. The markers are `http.new`, `http.param`, `http.register`, `sdk.client`, `socket`, `process`, `door`, `telemetry`, `store`, `chat.register` and `banned`. The pins hold in both directions: a count above its pin or an unlisted pair fails, and so does a row that no longer matches exactly. Routes hold. A Wrapped count is met by the construction rule. A Precedes count is met by the guard-call rule, and for an http.param by the stored-client rule: in a type that is not partial, and never in a record's primary constructor (whose parameter is also a public property), the parameter is only named, null-checked, configured, stored into a private field or property, or sent after a guard; every other mention of that holder is a configuration or guard-preceded; and at least one send is guard-preceded. A Factory http.register is met by D1: a non-declaration `AddAshlarEgressGuard(` after the registration, as a statement of its own innermost code block (the file root of a top-level program counts), never before it or in an enclosing or sibling block. An http.param `Factory` holds by the call proof, where every supply site passes, in the parameter's position, a client made by an `IHttpClientFactory`-typed receiver declared in that file (directly, through a never-reassigned local, through a same-file private factory method, or through the caller's own parameter proven the same way; a deconstruction into the local or parameter, a tuple swap included, counts as a reassignment), or by the typed proof, where the type is a typed client of a Factory-classified `AddHttpClient<…, T>` and nothing else constructs it. `Upstream:` holds for an http.param when the named file is its only supplier, builds no unguarded client, exposes no client field, property, indexer or explicit interface property, is not partial and is itself grounded (a Wrapped http.new or another routed http.param row), and for an http.register when the row's project cannot reach `Ashlar.Infrastructure` and every executable that composes it calls `AddAshlarEgressGuard` in its program, the named one included. `Governance` holds only for the four MEAI pairs, and only while F5 is green. An Exempt reason is in the closed set. Factory and Upstream reason about a closed world: every supply site of a receiving member is a production file in this scan, found by name, an auto-property initializer `T P { … } = new(…)` included; a positional record's primary constructor fails both routes, since `with` or an object initializer can replace its public property; a spelling the scan cannot follow (`CreateInstance`, `typeof(T)`, an empty-argument generic registration, a method group) fails the route instead of being assumed away; the receiving type must not be partial and, for a constructor, must be sealed; and a cycle fails. A raw construction is Wrapped only inside an `EgressHttp.Wrap(` or `EgressHttp.CreateClient(` argument region, or when a local declaration stores it and every later mention of that local, to the end of its block, is inside such an argument region or configures it (`name.Prop = …`), with at least one inside a wrap; a field, or a local returned, passed elsewhere or reassigned, stays unguarded. `ConfigureHttpClientDefaults(` occurs exactly once in production, in `EgressServiceCollectionExtensions.cs`, around `EgressHttp.CreateDelegatingHandler(`, and `Add…`, `typeof(HttpClient)` and `Get[Required][Keyed]Service[s]` occur nowhere. F4 also holds (A) every http.register in AddAshlar's own file is Factory; (B) `AddAshlarEgressGuard(` is declared exactly once in production, in `EgressServiceCollectionExtensions.cs`; (D) no production type implements `IHttpClientFactory` and nothing registers it directly (`Add…`, `typeof(IHttpClientFactory)`, `ServiceDescriptor.{Singleton|Scoped|Transient}`); and (E) every call of `AddAshlarEgressGuard(` covers at least one registration under D1, so a call in the wrong place fails. Chat and embedding registrations occur only in `MeaiPipelineServiceCollectionExtensions.cs`; its four `AddKeyedChatClient` statements each carry `.UseAshlarGovernance(` with the same key, compared on the raw text because cleaning blanks literals; the leaf chat clients are built only inside them or in a local that only they use, and the Bedrock client only in `AwsBedrockChatClientFactory.cs`; the four leaf types (`OllamaHttpChatClient`, `LlamaSharpChatClient`, `AwsBedrockChatClientFactory`, `IBedrockChatClientFactory`) occur only in their own files and that registration file; no raw `IChatClient` is registered (`Add…`, `typeof(IChatClient)`, `new ServiceDescriptor(typeof(IChatClient), …)`); and governance composes EgressGuard, then PolicyGate, then Sanitizing, then Auditing, with `new EgressGuardChatClient(` in no other production file. Every TSV id is a row of `docs/EgressInventory.md`, every `EG-` literal in production code is a row id there, and every row there whose route is not `Unscanned:*` is named by a TSV row; a docs file from which no `EG-` row parses fails rather than checking against nothing. Stated floors (1000 scanned files, 60 examined occurrences and 30 parsed docs rows; measured 2,145, 150 and 67 with PR 4.5's one file on top of PR 4.3's two on top of PR 4.10, after 2,142, 150 and 67 at PR 4.10, 2,135, 150 and 67 at PR 4.4, 2,134, 150 and 67 at PR 4.2, 2,133, 149 and 67 at PR 4.6, 2,130, 148 and 67 at 3b and 2,128, 146 and 67 at 3a; 4.1 adds one occurrence, 4.6 three files, 4.2 one file and one occurrence, 4.4 one file, 4.10 seven files, and 4.3 two files (the redirect handler and the factory binding filter; neither constructs an HTTP client, so the occurrence count stays 150), and 4.5 one file (`IEgressLabelledTool.cs`, no new HTTP client)), the six guard files, named anchor files per marker and the reach of the scan (`RemoteExecutionPlatform.cs`, `Ashlar.Agents.TestKit`, the CLI and `commercial/src/` in; four test projects out) stop an emptied scan passing. 113 named classification controls (72 at 3a) drive the classifier on real shapes, and 72 route controls build small fixture trees whose route must hold or fail with a named clause; the known misses they pin hold what the scan reports today, so the change that teaches the scan must flip them. The class runs 194 tests: 9 facts, 113 classification controls and 72 route controls. A failure prints the observed rows in TSV form, so a pin is updated mechanically. | `EgressGuardConventionTests`, `EgressGuardDecisionTests`, `EgressModeResolutionTests`, `EgressModeProcessBindingTests`, `EgressHttpHandlerTwinTests`, `EgressHttpNetstandard20TwinTests`, `EgressRedirectTwinTests`, `EgressFactoryDefaultsTwinTests`, `EgressKernelFactoryTwinTests`, `EgressGuardChatClientTwinTests`, `EgressRawClientTwinTests`, `EgressExplicitSiteTwinTests` | An outbound path arrives and nothing records it. The inventory found three that no gate saw: `ProviderFactory`'s static `new()` client, which sends prompts to OpenAI outside `UseAshlarGovernance`, under AirGapped too whenever `OPENAI_API_KEY` is set; the `IdeEndpoints.cs` fallback `new HttpClient()`; and the MCP `HttpClientTransport` built with two arguments, so the SDK builds a client of its own. 3b builds all three with `EgressHttp`. Without the pins, a routed site could go back to a raw client and stay green, and the next one would be added unlisted. Without the twins, the guard could stop deciding, start reading request bodies or throw into a caller while every static fact stayed green; without the netstandard2.0 twin, the asset .NET 5-7 apps resolve would run in no required check, and a synchronous `Send` through it could go out unevaluated again while every net8.0 twin stayed green; without the route twins, a route could hold on paper while the client it names records nothing. **A tripwire, not a proof, and report-only:** the scan is textual. It does not see HTTP clients built inside third-party SDKs, inbound server responses (`Unscanned:Inbound`), file egress through no door method, aliases, interpolation holes or reflection, or an executable chosen by a caller of a process funnel. Its guard-call rule is textual order, not temporal, and it reads braces only: a guard with no braces of its own counts for its enclosing block, so a guard in an unbraced `if` or loop body, in another `switch` case, or in an expression-bodied lambda or local function counts though it may never run. A new network SDK is caught only once its constructor is taught. The routes add their own blind spots: an Upstream supplier passing a static client defined in another file; a guard installed on a different `IServiceCollection` from the one the registration fills; a host outside the repository that composes a factory consumer with no Ashlar member that installs the guard (`AddAshlarFederatedBrickMesh` alone); reflection and target-typed `new(…)` suppliers in an argument, a `return` or an expression-bodied `T P => new(…)`, and a bare call through `using static` from another file; an explicit interface method that returns the client from an Upstream file; an `IHttpClientFactory` or raw `IChatClient` registration whose service type the text does not name; an early return between a registration and its guard, and an install call with no braces of its own; an `IHttpClientFactory`-typed receiver backed by a custom implementation, which only F4 (D) catches; an Upstream file that hands its client out through a non-private method or a `Func`, `Lazy`, array or tuple member, and a guarded holder mention that copies a stored client into another member sent unguarded elsewhere (both pinned as known misses); and every factory client records the family `http.factory`, the model calls included. So this proves the inventory is complete for the taught shapes, that each pinned route holds as the scan reads it, and that the guard decides as specified; it does not prove that any path is mediated. Every listed non-exempt site now reaches a decision and the guard refuses nothing; enforcement is PR 4. These are classification-style controls inside the runtime, not an accredited cross-domain solution. | +| **A nested egress subject never decides below a frame it was entered inside.** A decision's current label is the join of the high-water marks of every `EgressSubject` frame on the flow's chain, live or disposed, up to a detachment (the flow's own frame and every frame it was entered inside), read when the decision is made, with the innermost live frame's `subject:` basis, so a `Public` frame entered inside a `Secret` one decides `Secret`. Disposing a frame observes its mark into every frame it was entered inside, also from a child task whose frames never flow back out. `EgressSubject.Observe` joins a label into every frame on the chain at once, so an enclosing flow that egresses while a nested frame is still running already counts it; it only raises, and with no frame it does nothing. A frame disposed out of order still counts for the frame entered inside it (`Secret`, `LevelTooLow`), and a fire-and-forget task started inside a `Secret` frame still decides `Secret` after that frame's `using` ends, in the frame it had entered and in one it enters later, and with no frame of its own at what the frame's mark rose to after it ended, while the flow that ended it in order decides `Public`; what a sibling task reads after the parent ended, in a frame of its own or with none, still reaches the parent and so the sibling. A frame being disposed counts as live until its mark has reached the frames it was entered inside, so a task with no frame of its own inside it never decides below that mark, nor names the enclosing frame before it holds the mark. A flow leaves a frame only by disposing its own head while that head is undisposed, and goes back to exactly the frame that one was entered under, disposed or not; disposing a frame anywhere else moves no flow, and a flow whose head was already disposed never leaves it. So a flow that disposes its own frames out of order stays inside the outer frame it disposed (fail closed): a frame it enters afterwards joins that frame's mark (`Secret`), in every order of three (the six orders leave 13 frames on one flow, as a model of the rule says after every step), an async loop grows the flow's chain by exactly one frame per iteration (50 iterations, every length pinned), an enclosing live frame keeps the disposed frame's mark, and nothing stays reachable from the inner frame; and what such a flow reads later, with no frame of its own or in a frame it enters and ends in order, raises the outer frame's mark, which is the subject's shared one, so another session of that subject decides at it (`Confidential`, `LevelTooLow`). A task started inside a frame disposed first, also one started after an out-of-order dispose or handed a child scope to end, and the flow that entered a frame another flow disposed (a background task, `await Task.Run(parent.Dispose)`) stay inside it, in every dispose order and also after ending the frames around it, and read its mark when they decide, with no frame of their own or in one they enter, also what it rose to after it ended, by a direct raise or through the same mark entered in another frame (`Secret`). `EgressGuardDecisionTests.An_Internal_subject_may_reach_an_external_model_across_await_and_Dispose_restores_the_previous_frame` was flipped by name: its inner block pinned "inner Public decides Public". | `EgressSubjectNestingTests`, `EgressGuardDecisionTests` | Before SPEC-007 PR 4.4 the guard read only the innermost live frame, so any code that entered a fresh frame declassified everything its subject had read, and a nested subject's reads were lost when its frame ended. Once the guard enforces, either one is a write-down with no downgrade. A parent frame that ends first would otherwise declassify a child task still running inside it, whose closures can still hold the parent's data. If a flow could leave a frame other than by disposing its own head, some flow that never left a frame could decide below it. Staying inside fails closed, at two costs recorded as known limits in the `EgressSubject` remarks: a flow that disposes its frames out of order keeps the outer frame, so its chain grows with every repetition, which every later decision on it walks (availability), and what it reads later raises that subject's shared mark (another session's label). Neither arises on the flow that entered a frame and disposed it in a `using` block, in order, which every production `Enter` must be (PR 4.5); work created and started inside that frame, such as a task or a thread, still keeps it for as long as it runs, so what it reads later still raises the frame's shared mark. Work keeps the frames of the flow where it captured the execution context: a `Task`, a `System.Threading.Timer`, a registration or a continuation where it is created, a `Thread` or a `System.Timers.Timer` where it is started. So what is read by a `Task`, `System.Threading.Timer`, registration or continuation created before the frame and started or triggered inside it, such as a cold task, or by a thread created inside it and started after the `using` ends, never reaches the frame's mark, and a decision in the frame can be below it: a producer creates and starts the work it reads through inside its `using` block. Such an `Enter` is never inside an async helper, whose frame does not reach the flow that awaits it, and never across a `yield return` of an async iterator, whose body resumes on its consumer's flow, so the rest of the block would decide at the consumer's frames and its reads would never reach the frame's mark. If a flow still inside a disposed frame read only the mark that frame had when it ended, a later read by the same subject, under the same mark elsewhere, would not reach it. Report-only until the switch (4.11). Production enters a frame only at the floor-pin sites (PR 4.5): `SelfExtendRunnerAdapter.RunAsync` at `SecurityLabel.SystemHigh`. | +| **A read counts at what it reported only when it returned and reported; otherwise it counts as `SystemHigh`.** `EgressSubject.BeginRead()` returns a `ReadScope`, which observes into every frame of the chain it was begun on when it is disposed: after `Complete()` with no `Report`, `SystemHigh`; after `Complete()` with reports, their join and nothing else (a `Public` report reads nothing and raises no mark); without `Complete()`, as when the read throws, `SystemHigh` even after a report. `EgressSubject.Observe` never satisfies a scope. The scope observes into the chain it was begun on from whatever flow it ends on, including one that never had that chain (`ExecutionContext.SuppressFlow`, a thread started without the execution context, or a callback run detached); an unreported read begun in a task with no frame of its own after its parent frame ended still raises that parent, so a sibling task inside it decides `SystemHigh`; a report after it ended still reaches the frames from any flow, concurrent reports all count, and with no frame a scope changes nothing. | `EgressSubjectReadScopeTests` | Without the unreported-read rule, an unlabelled tool result counts as nothing read and the agent's next model call carries it at the floor. Without the completion rule, a tool that reads and then throws sends its data out in the exception's message at whatever it reported first. If `Observe` satisfied a scope, any tool could launder its result by observing `Public` on a side value. `ToolCallingAgent` begins a read around each tool call and accepts `Report` only from `IEgressLabelledTool` (`RAGTool` in PR 4.5); an unlabelled tool or a throw is `SystemHigh`. | | **Work handed to another component is not decided at its caller's mark.** The internal, raise-only `EgressSubject.RunDetached(Action)` runs a callback with no subject on the calling flow and puts back exactly the caller's frame when it returns or throws, and when it throws, before any exception filter of the caller runs, since an exception's first pass runs filters before any `finally` block: the callback's own filters and `finally` blocks run detached, and a filter of the caller decides at the caller's mark (`Secret`, `LevelTooLow`), also around a nested callback, and what it reads, by `Observe` or a read scope, raises the caller's frame, and a frame it enters stays on the caller's flow until the flow disposes it (`Secret`, two frames on the flow); a decision inside it has no subject (`SystemHigh`); a frame entered inside it starts a chain of its own, whose reads never reach the caller's frames; a frame the callback enters and leaves undisposed is dropped from the calling flow, which decides at its own mark (`Internal`), also after it disposes that frame; a task inside a parent frame that has ended is back inside that parent after an `AgentBus` publish, and a frame it enters afterwards still decides at the parent's mark (`Secret`); and a task started inside the callback, and a `Task`, `System.Threading.Timer`, registration or continuation created inside it wherever it is started or triggered, keep no subject for their whole life: a task started inside it, after the callback returns, after the caller's frame ends, and when a frame it was handed ends after the callback returned, and a cold task, a timer, a cancellation registration and a continuation created inside it, wherever they are started or triggered (`SystemHigh`). Each of those captures the flow where it is created, so one the caller created keeps the caller's frame although the callback starts or triggers it (`Secret`, `LevelTooLow`); a thread and a `System.Timers.Timer` capture it where they are started, so one the caller created and the callback starts runs detached (`SystemHigh`), and one the callback created and the caller starts runs in the caller's frame (`Secret`, `LevelTooLow`). No caller holds the detachment, so none can end it out of order, on another flow, or by handing it to a task, and no program on one flow leaves the flow detached, or below a live frame it entered outside every callback: not the 24 that dispose three frames entered inside the callback in every order, 0 to 3 of them before it returns and the rest after it (each back in the caller's frame, at its mark, with one frame on the flow), and not any of the 2,092 programs of an enclosing frame, up to two more frames and up to two callbacks, nested or in turn, each checked step by step against a model of the rule, whose flows keep only the frames they entered outside the callbacks and disposed out of order (2,474). Read from its compiled body, `RunDetached` writes the flow's frame only inside a try that catches everything or in that catch (the detachment and both restores). `AgentBus.PublishAsync` starts every subscriber's task inside it, so a subscriber decides with no subject while the publisher is inside a frame, and the publisher's own frame is restored. A convention fact pins every `RunDetached` call site in the repository's C#, every tree but build output, dot directories and nested checkouts (exactly `AgentBus.cs` x1, over a floor of 1000 scanned files), and that the bus's `Task.Run(` and `subscription.Handler(` sit inside the callback and nowhere else in the file. | `EgressSubjectDetachTests` | `Task.Run` captures the publisher's execution context, so before SPEC-007 PR 4.4 a subscriber's egress was decided at the publisher's mark and recorded as the publisher's. A detachment a caller could hold could be disposed out of order, which left that flow detached below the live frame it was inside, so a frame it entered later decided below that frame: a write-down. A callback cannot be ended out of order. A restore made only in a `finally` would run a filter of the caller, such as `catch (Exception ex) when (Log(ex))`, at the callback's frame, below the live frame the caller is inside: a write-down, and its reads would miss the caller's frames. A restore repeated in a `finally` after the catch would run after that filter, in the rethrow's second pass, and drop a frame the filter entered, which the flow never disposed. A write of the flow's frame outside that try and its catch, such as a restore after the try, leaves a window in which an asynchronous abort (`ControlledExecution.Run` on .NET 7 and later) leaves `RunDetached` with the flow detached: the caller's filter for it decides below the caller's mark and what it reads misses the caller's frames, and the flow stays detached afterwards. A `Task`, a `System.Threading.Timer`, a registration or a continuation captures the flow where it is created, and a `Thread` or a `System.Timers.Timer` where it is started, so a new site must create and start the work it hands off inside the callback: a task created before it and started inside keeps the caller's frame, and a thread created inside it and started after it returns runs in the caller's frame. `RunDetached` only raises, but a call placed around a read would hide that read from the caller's frames, so a new site needs its reason recorded here. A tripwire, not a proof: a call through an alias, a delegate or reflection is not seen. | +| **Every production `EgressSubject.Enter` is pinned with its floor and the method that encloses it.** The scan is the egress inventory's production trees (`src`, `application`, `applications`, `commercial`, `products`, `tools`, `consumer-template`), pruning build output, dot directories, nested checkouts and test projects by csproj content, never by name. Each call is recorded as file, enclosing method, the `new HighWaterMark(...)` argument and a count. The pin and the scan must match in both directions: a production `Enter` that is not listed fails, and a listed row that the scan does not find fails. Below 1000 scanned files the scan is taken to have lost its reach. PR 4.5 has one site: `SelfExtendRunnerAdapter.cs`, method `RunAsync`, floor `SecurityLabel.SystemHigh`. Test projects are not pinned. A tripwire, not a proof: an alias, a delegate or reflection is not seen. | `EgressSubjectFloorPinTests` | A runner that entered at `Public`, or in a helper whose frame does not reach the flow that awaits it, would decide below the unlabelled carry-over in the snapshot, and once the switch is on that is a write-down. A new production `Enter` with no row would ship the same way. The floor is part of the pin so a later runner that declares below `SystemHigh` has to say so here. | | **AirGapped keeps remote execution local, and AirGapped and SecureWorkstation refuse a non-loopback inbound listener.** Composing `AddAshlar(AirGapped)` makes an overnight job `ExecutionTarget.Local` with the reason `AirGapped: remote execution unavailable; running locally (Overnight/background job forces remote execution.)`, and peer-network-only throws. The same overnight job on Full is still `ExecutionTarget.Remote`. Single-image vision and LLM on AirGapped never call the inner factory with `openai` or `azure` (a fake inner records the names); on Full the vision and LLM lists still contain both. Boot validation (`IOptions.Value`, the `ValidateOnStart` path) refuses, on AirGapped only, `EnablePeerNetworkRouting`, a non-empty `RemoteCatalogBaseUrls`, mesh-lab `Enabled` and Bedrock `Enabled`; the same flags succeed on Full, and a plain AirGapped composition leaves them off. The ollama.com catalog `Enabled` is false on AirGapped and true on Full. MCP over HTTP (`WithAshlarHttpTransport`) fails options validation on SecureWorkstation; stdio (`WithStdioServerTransport`) boots; AirGapped still refuses an enabled server with no HTTP marker. A non-loopback URL (`0.0.0.0`, `+`, `*`, a routable address) fails that validation on AirGapped and SecureWorkstation; `127.0.0.1`, `localhost`, `::1` and an empty list succeed; Full allows `http://+:8080`. The class resets process egress state on every composition. | `AirGappedSecureWorkstationHygieneCertificationTests` | Without it, AirGapped still selects RunPod for an overnight job (the default remote target, base URL `https://api.runpod.io`) and single-image vision still tries OpenAI, and a SecureWorkstation or AirGapped host can listen on every interface. Responses on those listeners are not mediated until PR 5. | 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 — 133 `.cs` files in -`src/Ashlar.Tests.Infrastructure/Tests/Certification/` as of 2026-10-06 (136 entries in all; the +campaigns, the analyzer fence, sandbox-escape tests and the dogfood suites — 135 `.cs` files in +`src/Ashlar.Tests.Infrastructure/Tests/Certification/` as of 2026-10-06 (138 entries in all; the other three are golden JSON fixtures). This figure read 31, then 68, then 82, then 111, then 113, -then 120, then 125, then 127, then 128, then 131, then 132, before it read 133; count it rather than quoting it. The +then 120, then 125, then 127, then 128, then 131, then 132, before it read 135; count it rather than quoting it. The table above is only the *conventions*: assertions about how the repository is allowed to be shaped, which have no other home and which nothing else would catch. diff --git a/docs/EgressInventory.md b/docs/EgressInventory.md index b82638c27..e65ccaffb 100644 --- a/docs/EgressInventory.md +++ b/docs/EgressInventory.md @@ -23,6 +23,10 @@ This file lists every path by which a request or data leaves an Ashlar process, - **On the netstandard2.0 asset a walker of only `DelegatingHandler.InnerHandler` still stops at the synchronous-send hop.** The hop is there on every runtime that binds that asset, .NET Framework, classic Mono and Unity included: the `InnerHandler` of an `EgressHttp.Wrap` handler is still the hop, not the caller's handler, and the hop is not a `DelegatingHandler`, so that walker stops and does not see the caller's handler. A walker that also steps through the hop's internal `Inner` continues and sees the redirect handler, then the primary. A synchronous `Send` still throws from the hop before the redirect handler runs, with no decision. Grpc.Net.Client walks `InnerHandler` and, on this asset, stops at the hop. `Ashlar.Transport.Grpc` targets net8.0 and net10.0, where the chain is the guard handler, then the redirect handler (itself a `DelegatingHandler`), then the `HttpClientHandler`, so that walk still finds the `HttpClientHandler`. - **Two copies of `Ashlar.Abstractions` in one process darken one `Ashlar-Egress` event source.** A process that loads a second copy (a plugin `AssemblyLoadContext`, for example) has two event sources of that name. .NET will not build an event source's descriptors while another live source has the same name and GUID (`EventSource_EventSourceGuidInUse`, unless `System.Diagnostics.Tracing.EventSource.AllowDuplicateSourceNames` is set), so a copy whose source was not enabled before the other copy's appeared can never be enabled: EventPipe, ETW and `EventListener` consumers see none of its decisions. Each copy's decisions still reach that copy's own `EgressDecisionLog` subscribers. The Debug log subscribes only to the copy that `Ashlar.Infrastructure` binds (`EgressDecisionLoggerSubscription`), so a second copy with no subscriber of its own and a dark event source records its decisions nowhere. Found by the SPEC-007 PR 4.2 twin, whose harness enables the guard's own source before it loads the netstandard2.0 copy. +**Producers (SPEC-007 PR 4.5, report-only).** Nothing here refuses, and no request changes. The only production frame is `SelfExtendRunnerAdapter.RunAsync` (`src/Ashlar.BackgroundAgents.HostRunners/SelfExtendRunnerAdapter.cs`), which enters `agent:` at `SystemHigh` around the cycle and the post-cycle admission and auto-share. The floor is `SystemHigh` because the snapshot carries unlabelled carry-over. Records for that cycle go from `no-subject` to `subject:agent:` at `SystemHigh`. The `using` is on the flow that enters it, disposed in order, and it does not span a `yield return`. Overloads that only await `RunAsync` do not enter. `ToolCallingAgent` begins a `ReadScope` around each tool invocation and calls `Complete` only when the tool returns. A tool may `Report` only when it implements `IEgressLabelledTool` and the toolbox is a `CapabilityRegistry` that can find it; otherwise the report is ignored and the unreported scope is `SystemHigh`. A tool that throws, a refusal that throws included, is not completed, so dispose observes `SystemHigh`. In #721 the only labelled tool is `RAGTool`. It reports each hit: trim the sensitivity name, resolve it with `GetByName`, and map it with `ToDataLabel` only when the level is one of `DataSensitivityLevels.All`. Canonical names are the five primitives plus `top-secret`, any case, trimmed. A custom level, an unknown name, or an unmarked hit is `SystemHigh`. Zero hits, and the unrankable-query refusal (a returned result, not a throw), report `Public` ("read nothing"). A model response is not a read. A response from a chat target whose key starts with `peer:` is another agent's output and is observed as `SystemHigh` after the response returns, until PR 5. `local:` and `cloud:` are not. No new egress site is added, so `ci/egress-inventory.tsv` is unchanged. + +- **Within-call egress (known limit, PR 4.5).** A `BeginRead` scope observes only when it ends, which is the rule PR 4.4 shipped. A tool that reads and then egresses inside the same call, before the scope ends, is decided at the pre-read mark. This matters for a runner whose floor is below `SystemHigh`. The production self-extend floor is `SystemHigh`, so the limit does not declassify that runner. The owner can reverse it. Making an open scope count as `SystemHigh` would turn the leak scenario from `LevelTooLow` into `SystemHighData`: a mark only rises, and a later Secret report could not lower a mark the open scope had already raised. + **Profiles.** | Code | Profile | diff --git a/docs/knowledge-graph.json b/docs/knowledge-graph.json index 193151c68..2aeae89f3 100644 --- a/docs/knowledge-graph.json +++ b/docs/knowledge-graph.json @@ -15,8 +15,8 @@ "operator_facing_config_variables": 155, "operator_vars_missing_from_configuration_reference": 18, "operator_vars_absent_from_all_documentation": 0, - "test_files_declaring_facts": 774, - "declared_facts": 4902, + "test_files_declaring_facts": 776, + "declared_facts": 4915, "workflows": 61 }, "operator_vars_missing_from_configuration_reference": [ @@ -5203,6 +5203,13 @@ "declared_facts": 18, "in_cert_gate_filter": true }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectFloorPinTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", + "declared_facts": 1, + "in_cert_gate_filter": true + }, { "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectNestingTests.cs", "project": "Ashlar.Tests.Infrastructure", @@ -5210,6 +5217,13 @@ "declared_facts": 27, "in_cert_gate_filter": true }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectProducerTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", + "declared_facts": 12, + "in_cert_gate_filter": true + }, { "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectReadScopeTests.cs", "project": "Ashlar.Tests.Infrastructure", diff --git a/docs/knowledge-graph.md b/docs/knowledge-graph.md index b6047660b..147da370e 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) | 155 | | missing from `docs/Configuration.md` | 18 | | **mentioned in no document at all** | **0** | -| test files declaring xUnit facts | 774 | -| declared facts | 4902 | +| test files declaring xUnit facts | 776 | +| declared facts | 4915 | | workflows | 61 | ## Namespaces cert-gate selects diff --git a/docs/specs/SPEC-007-security-labels-and-reference-monitor.md b/docs/specs/SPEC-007-security-labels-and-reference-monitor.md index 4d2a36617..8b9a49586 100644 --- a/docs/specs/SPEC-007-security-labels-and-reference-monitor.md +++ b/docs/specs/SPEC-007-security-labels-and-reference-monitor.md @@ -57,7 +57,7 @@ The status line above and the starting prompt are the owner's, as written; the s - 4.2 the synchronous `Send` gap; - 4.3 redirects; - 4.4 frame semantics: monotone nesting, `Observe`, read scopes; - - 4.5 subject producers at the agent runners, report-only. Obligation carried from 4.4: every production + - 4.5 subject producers at the agent runners, report-only (#721). Obligation carried from 4.4: every production `EgressSubject.Enter` is a `using` on the flow that enters it (never inside an async helper, whose frame does not reach the flow that awaits it), disposed in order, since a flow that disposes frames out of order stays inside the outer one (its chain grows with each repetition, and what it reads later raises the subject's shared mark). Work a @@ -130,6 +130,16 @@ The status line above and the starting prompt are the owner's, as written; the s (`ProcessGlobalEnvironmentConventionTests.Only_AddAshlar_and_the_reset_seam_reach_the_process_egress_state`) reads every source file, Orchestration's included, so no new caller of the reset seam or the mode latch setters appears unlisted. +- **PR 4.5** (#721), still report-only. `SelfExtendRunnerAdapter.RunAsync` enters `agent:` at `SystemHigh` + around the cycle and the post-cycle admission and auto-share, because the snapshot carries unlabelled carry-over. + Records go from `no-subject` to `subject:agent:` at `SystemHigh`. Nothing refuses. `ToolCallingAgent` begins a + read around each tool call and accepts `Report` only from `IEgressLabelledTool`; in #721 the only labelled tool + is `RAGTool`, which reports each hit's canonical label and `Public` when it read nothing. An unlabelled tool, or a + tool that throws, is `SystemHigh`. A response from a `peer:` chat target is observed as `SystemHigh` after it + returns; a model endpoint is not a read. An open read scope still observes only when it ends (the merged 4.4 rule): + a tool that egresses before the scope ends is decided at the pre-read mark. That is a known limit for a runner whose + floor is below `SystemHigh`. The owner can reverse it. Reversing it, so an open scope counted as `SystemHigh`, + would turn the leak's `LevelTooLow` into `SystemHighData`, because a mark only rises. - **PR 4.10** (#720) AirGapped and SecureWorkstation hygiene. The profile reaches Infrastructure through `AshlarResolvedDeploymentProfileOptions`, which `AddAshlar` registers from the resolved profile. On AirGapped, every remote routing reason runs locally (`AirGapped: remote execution unavailable; running locally ()`); diff --git a/src/Ashlar.AI.Pipeline/Governance/AshlarGovernanceChatClientBuilderExtensions.cs b/src/Ashlar.AI.Pipeline/Governance/AshlarGovernanceChatClientBuilderExtensions.cs index 6b91f93cc..c763a5c47 100644 --- a/src/Ashlar.AI.Pipeline/Governance/AshlarGovernanceChatClientBuilderExtensions.cs +++ b/src/Ashlar.AI.Pipeline/Governance/AshlarGovernanceChatClientBuilderExtensions.cs @@ -27,7 +27,7 @@ public static ChatClientBuilder UseAshlarGovernance(this ChatClientBuilder build // First Use = outermost → the egress guard records the attempt before PolicyGate decides it. builder.Use((inner, sp) => - new EgressGuardChatClient(inner, MeaiEgressDestination.Resolve(targetKey, sp, inner), ResolveEgressGuard(sp))); + new EgressGuardChatClient(inner, MeaiEgressDestination.Resolve(targetKey, sp, inner), ResolveEgressGuard(sp), targetKey)); builder.Use((inner, sp) => { diff --git a/src/Ashlar.AI.Pipeline/Governance/EgressGuardChatClient.cs b/src/Ashlar.AI.Pipeline/Governance/EgressGuardChatClient.cs index 15a23729d..ab041a924 100644 --- a/src/Ashlar.AI.Pipeline/Governance/EgressGuardChatClient.cs +++ b/src/Ashlar.AI.Pipeline/Governance/EgressGuardChatClient.cs @@ -1,3 +1,4 @@ +using Ashlar.Abstractions.Security; using Ashlar.Abstractions.Security.Egress; using Microsoft.Extensions.AI; @@ -15,6 +16,12 @@ namespace Ashlar.AI.Pipeline.Governance; /// an inner layer throws synchronously (PolicyGate's denial) still leaves this call synchronously, as it did before. /// A request (the in-process LLamaSharp client under local:onnx) delegates without /// deciding. +/// Peer responses (SPEC-007 PR 4.5). A response from a target whose key starts with peer:, +/// in any case, is another agent's output. It is unlabelled until PR 5, so after the response returns — or faults — +/// the layer observes . The decision for this call was already recorded, at +/// the mark from before the response. Model endpoints (local:, cloud:) are not a read. With no frame, +/// the observe does nothing. An inner layer that throws synchronously, as PolicyGate does, still leaves this call +/// synchronously and is not observed: no response came back. /// Ollama cloud models (SPEC-007 PR 4.1). A model whose id ends in -cloud or :cloud, in any /// case, runs on ollama.com: the local Ollama daemon relays the conversation there. A call with such a model is /// recorded as an external model at https://ollama.com, under the target's site, whatever the target's own @@ -29,17 +36,33 @@ public sealed class EgressGuardChatClient : DelegatingChatClient private readonly IEgressGuard _guard; private readonly string? _defaultModelId; + private readonly string? _targetKey; - /// Creates the guard layer around an inner client. + /// Creates the guard layer around an inner client. No target key, so a response is not a read. /// The client this layer delegates to. /// What each call is reported as, or for an in-process target. /// The guard; means . public EgressGuardChatClient(IChatClient innerClient, EgressRequest? request, IEgressGuard? guard = null) + : this(innerClient, request, guard, targetKey: null) + { + } + + /// Creates the guard layer around an inner client for a governed target key. + /// The client this layer delegates to. + /// What each call is reported as, or for an in-process target. + /// The guard; means . + /// + /// The governed chat target. A key that starts with peer: observes + /// after a response. Model endpoints are not reads. Required, so this overload does not collide with the + /// three-argument constructor. + /// + public EgressGuardChatClient(IChatClient innerClient, EgressRequest? request, IEgressGuard? guard, string? targetKey) : base(innerClient) { Request = request; _guard = guard ?? EgressGuard.ProcessDefault; _defaultModelId = DefaultModelIdOf(innerClient); + _targetKey = targetKey; } /// @@ -55,7 +78,8 @@ public override Task GetResponseAsync( CancellationToken cancellationToken = default) { Decide(options); - return base.GetResponseAsync(messages, options, cancellationToken); + var pending = base.GetResponseAsync(messages, options, cancellationToken); + return ObservesAgentBackedResponse ? ObserveAfterAsync(pending) : pending; } /// @@ -65,7 +89,40 @@ public override IAsyncEnumerable GetStreamingResponseAsync( CancellationToken cancellationToken = default) { Decide(options); - return base.GetStreamingResponseAsync(messages, options, cancellationToken); + var pending = base.GetStreamingResponseAsync(messages, options, cancellationToken); + return ObservesAgentBackedResponse ? ObserveStream(pending) : pending; + } + + // A peer response is another agent's output. Model endpoints are not. Request null (in-process local:onnx) + // is not a response that left a model endpoint we record, and it is not observed. + private bool ObservesAgentBackedResponse => + Request is not null + && !string.IsNullOrWhiteSpace(_targetKey) + && _targetKey.Trim().StartsWith("peer:", StringComparison.OrdinalIgnoreCase); + + private static async Task ObserveAfterAsync(Task pending) + { + try + { + return await pending.ConfigureAwait(false); + } + finally + { + EgressSubject.Observe(SecurityLabel.SystemHigh); + } + } + + private static async IAsyncEnumerable ObserveStream(IAsyncEnumerable pending) + { + try + { + await foreach (var update in pending.ConfigureAwait(false)) + yield return update; + } + finally + { + EgressSubject.Observe(SecurityLabel.SystemHigh); + } } /// diff --git a/src/Ashlar.Abstractions/PublicAPI.Unshipped.txt b/src/Ashlar.Abstractions/PublicAPI.Unshipped.txt index bcb5f47b2..7b40b8c8a 100644 --- a/src/Ashlar.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Ashlar.Abstractions/PublicAPI.Unshipped.txt @@ -55,6 +55,8 @@ Ashlar.Abstractions.Security.Egress.IEgressDecisionSink Ashlar.Abstractions.Security.Egress.IEgressDecisionSink.Record(Ashlar.Abstractions.Security.Egress.EgressDecision! decision) -> void Ashlar.Abstractions.Security.Egress.IEgressGuard Ashlar.Abstractions.Security.Egress.IEgressGuard.Evaluate(Ashlar.Abstractions.Security.Egress.EgressRequest! request) -> Ashlar.Abstractions.Security.Egress.EgressDecision! +Ashlar.Abstractions.Security.Egress.IEgressLabelledTool +Ashlar.Abstractions.Security.Egress.IEgressLabelledTool.ReportRead(Ashlar.Abstractions.Security.Egress.ReadScope! read, Ashlar.Abstractions.ToolResult! result) -> void Ashlar.Abstractions.Security.Egress.ReadScope Ashlar.Abstractions.Security.Egress.ReadScope.Complete() -> void Ashlar.Abstractions.Security.Egress.ReadScope.Dispose() -> void diff --git a/src/Ashlar.Abstractions/Security/Egress/IEgressLabelledTool.cs b/src/Ashlar.Abstractions/Security/Egress/IEgressLabelledTool.cs new file mode 100644 index 000000000..5ab174f95 --- /dev/null +++ b/src/Ashlar.Abstractions/Security/Egress/IEgressLabelledTool.cs @@ -0,0 +1,21 @@ +namespace Ashlar.Abstractions.Security.Egress; + +/// +/// A tool that labels everything its result carries. The agent that holds the read scope accepts +/// only from a tool that implements this. Any other tool's read stays unreported +/// and is observed as when the scope ends. +/// +/// +/// The scope is not ambient. A tool cannot reach it except through this method, and the holder decides +/// which tools may call it. In PR 4 the only labelled tool is RAGTool. +/// +public interface IEgressLabelledTool +{ + /// + /// Reports the label of everything carries into , before + /// the holder completes the scope. A read that returned nothing reports . + /// + /// The scope the holder began around this tool call. Still open. + /// What the tool returned. + void ReportRead(ReadScope read, ToolResult result); +} diff --git a/src/Ashlar.BackgroundAgents.HostRunners/SelfExtendRunnerAdapter.cs b/src/Ashlar.BackgroundAgents.HostRunners/SelfExtendRunnerAdapter.cs index 761e7dbce..91fd804b2 100644 --- a/src/Ashlar.BackgroundAgents.HostRunners/SelfExtendRunnerAdapter.cs +++ b/src/Ashlar.BackgroundAgents.HostRunners/SelfExtendRunnerAdapter.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Logging; using Ashlar.Abstractions; +using Ashlar.Abstractions.Security; +using Ashlar.Abstractions.Security.Egress; using Ashlar.BackgroundAgents.Agents; using Ashlar.BackgroundAgents.Configuration; using Ashlar.BackgroundAgents.DataSensitivity; @@ -243,6 +245,11 @@ public async Task RunAsync( // through the policy engine and feeding observations back into the conversation. // The previous single-turn AgentHost path was unable to chain list → read → write // because tool results never reached the LLM after the first round. + // Floor is SystemHigh: the snapshot carries unlabelled carry-over. This using is on the + // flow that enters it, covers the cycle and the post-cycle admission and auto-share, and + // does not span a yield return. Overloads that await this method do not enter. + using (EgressSubject.Enter("agent:" + resolvedAgentId, new HighWaterMark(SecurityLabel.SystemHigh))) + { var memory = tools.MemoryFor(agent); var cycle = await agent .RunCycleAsync(snapshot, tools, policies, onRejected: null, memory, cancellationToken) @@ -301,6 +308,7 @@ public async Task RunAsync( cycle.Iterations, cycle.StoppedReason, gateOutcome); + } } catch (Exception ex) { diff --git a/src/Ashlar.BackgroundAgents/Agents/ToolCallingAgent.cs b/src/Ashlar.BackgroundAgents/Agents/ToolCallingAgent.cs index a651ee8a8..63241f578 100644 --- a/src/Ashlar.BackgroundAgents/Agents/ToolCallingAgent.cs +++ b/src/Ashlar.BackgroundAgents/Agents/ToolCallingAgent.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Ashlar.Abstractions; +using Ashlar.Abstractions.Security.Egress; using Ashlar.Core.Application.Execution.Ports; using Ashlar.Runtime; @@ -231,14 +232,27 @@ public async Task RunCycleAsync( } ToolResult result; - try + using (var read = EgressSubject.BeginRead()) { - result = await tools.InvokeAsync(call, snapshot, loopCt).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - stoppedReason = "deadline"; - throw; + try + { + result = await tools.InvokeAsync(call, snapshot, loopCt).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + stoppedReason = "deadline"; + throw; + } + + // Only a tool that declares itself labelled may report. Anything else stays + // unreported, so disposing the scope observes SystemHigh (SPEC-007 PR 4.5). + if (tools is CapabilityRegistry registry + && registry.Find(call.Id) is IEgressLabelledTool labelled) + { + labelled.ReportRead(read, result); + } + + read.Complete(); } deltas.Add(policies.Sign(result.Delta)); diff --git a/src/Ashlar.BackgroundAgents/Ashlar.BackgroundAgents.csproj b/src/Ashlar.BackgroundAgents/Ashlar.BackgroundAgents.csproj index 6ed0e76dc..08e998bf6 100644 --- a/src/Ashlar.BackgroundAgents/Ashlar.BackgroundAgents.csproj +++ b/src/Ashlar.BackgroundAgents/Ashlar.BackgroundAgents.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityLabelBridge.cs b/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityLabelBridge.cs index c71abc64a..af2805398 100644 --- a/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityLabelBridge.cs +++ b/src/Ashlar.BackgroundAgents/DataSensitivity/DataSensitivityLabelBridge.cs @@ -8,7 +8,8 @@ namespace Ashlar.BackgroundAgents.DataSensitivity; /// on data () and once as a clearance (). /// /// -/// A bridge only. Nothing consults it yet. , +/// A bridge only. RAGTool calls when it reports a hit whose level is +/// one of the five primitives (SPEC-007 PR 4.5). , /// , the markers and the RAG filters decide exactly as they did before it /// existed, and none of them calls this class or . A cert-gate parity test holds the /// two decisions together: for levels whose lies in 0 to 4, diff --git a/src/Ashlar.BackgroundAgents/RAG/RAGTool.cs b/src/Ashlar.BackgroundAgents/RAG/RAGTool.cs index e09465d7b..8ee9b92a7 100644 --- a/src/Ashlar.BackgroundAgents/RAG/RAGTool.cs +++ b/src/Ashlar.BackgroundAgents/RAG/RAGTool.cs @@ -1,5 +1,7 @@ using System.Text.Json; using Ashlar.Abstractions; +using Ashlar.Abstractions.Security; +using Ashlar.Abstractions.Security.Egress; using Ashlar.BackgroundAgents.DataSensitivity; namespace Ashlar.BackgroundAgents.RAG; @@ -15,7 +17,7 @@ namespace Ashlar.BackgroundAgents.RAG; /// 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 +public sealed class RAGTool : ITool, IEgressLabelledTool { /// /// Default tool id. @@ -96,15 +98,66 @@ public async Task InvokeAsync(ToolCall toolCall, WorldSnapshot s, Ca var refusal = new[] { $"RAG search REFUSED: query='{query}' cannot be ranked. {ex.Message}" }; return new ToolResult( new ActionDelta(tick, tick + 1, refusal), - new { Refused = true, Reason = ex.Message }); + new RagRefusal(Refused: true, Reason: ex.Message)); } var log = new[] { $"RAG search: query='{query}', results={results.Count}" }; var delta = new ActionDelta(tick, tick + 1, log); - var payload = results.Select(r => new { r.Id, r.Text, r.Score, r.SensitivityLevelName }).ToList(); + var payload = results.Select(r => new RagHit(r.Id, r.Text, r.Score, r.SensitivityLevelName)).ToList(); return new ToolResult(delta, payload); } + /// + /// Reports the label of every hit, or when the search read nothing + /// (no hit, or the unrankable-query refusal). Canonical names are the five primitive levels plus + /// top-secret, any case, trimmed. Anything else is . + /// + public void ReportRead(ReadScope read, ToolResult result) + { + ArgumentNullException.ThrowIfNull(read); + switch (result?.Payload) + { + case RagRefusal: + read.Report(SecurityLabel.Public); // read nothing: refused + return; + case IReadOnlyList hits when hits.Count == 0: + read.Report(SecurityLabel.Public); // read nothing: no hit + return; + case IReadOnlyList hits: + foreach (var hit in hits) + read.Report(MapHitLabel(hit.SensitivityLevelName, _sensitivityRegistry)); + return; + default: + read.Report(SecurityLabel.SystemHigh); + return; + } + } + + /// + /// The data label of one hit name: trim, , and + /// only when the level is one of + /// . Anything else, a custom level included, is + /// . + /// + internal static SecurityLabel MapHitLabel(string? name, IDataSensitivityRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + if (string.IsNullOrWhiteSpace(name)) + return SecurityLabel.SystemHigh; + + var level = registry.GetByName(name.Trim()); + if (level is null) + return SecurityLabel.SystemHigh; + + foreach (var primitive in DataSensitivityLevels.All) + { + if (ReferenceEquals(level, primitive)) + return level.ToDataLabel(); + } + + return SecurityLabel.SystemHigh; + } + /// /// The agent's clearance, narrowed (never widened) by what the model asked for. Null -- the /// floor, downstream -- when the snapshot carries no agent clearance. @@ -139,6 +192,10 @@ private static RAGSearchArgs ParseArgs(ToolCall call) } } + private sealed record RagHit(string Id, string Text, double Score, string? SensitivityLevelName); + + private sealed record RagRefusal(bool Refused, string Reason); + private sealed class RAGSearchArgs { [System.Text.Json.Serialization.JsonPropertyName("query")] diff --git a/src/Ashlar.Runtime/CapabilityRegistry.cs b/src/Ashlar.Runtime/CapabilityRegistry.cs index f44f0f762..37e59527d 100644 --- a/src/Ashlar.Runtime/CapabilityRegistry.cs +++ b/src/Ashlar.Runtime/CapabilityRegistry.cs @@ -22,6 +22,16 @@ public sealed class CapabilityRegistry : IToolbox /// Registers a tool for agent invocation. public void Register(ITool tool) => _tools[tool.Id] = tool; + /// The tool registered under , or when none is. + public ITool? Find(string? id) + { + // string.IsNullOrEmpty does not narrow id on the netstandard2.0 asset, so TryGetValue + // would be CS8604 there. Null and empty both miss, as IsNullOrEmpty did. + if (id is null || id.Length == 0) + return null; + return _tools.TryGetValue(id, out var tool) ? tool : null; + } + /// Returns schemas for all registered tools. public IEnumerable Schemas() => _tools.Values.Select(t => t.Schema); diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressGuardConventionTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressGuardConventionTests.cs index d8e1e287c..25ee33993 100644 --- a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressGuardConventionTests.cs +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressGuardConventionTests.cs @@ -203,10 +203,12 @@ public sealed partial class EgressGuardConventionTests /// (http.new 20, of which 4 are the guard's own, pinned Exempt:GuardImpl). SPEC-007 PR 4.4 adds one file /// with no outbound path, ReadScope.cs: 2,135 files, 150 occurrences, as measured in the devtest container /// with all four merged. SPEC-007 PR 4.10 adds seven files with no outbound path (the resolved-profile options, - /// four AirGapped boot validators, the inbound-listener policy and the MCP HTTP marker): 2,142 files, 150 - /// occurrences. SPEC-007 PR 4.3 adds the redirect handler and the factory binding filter, neither of which - /// constructs an HTTP client: 2,144 files, 150 occurrences. Set far enough below to survive ordinary - /// deletions; a scan that stops reading the tree falls through them. Re-measure and restate when a PR moves them. + /// four AirGapped boot validators, the inbound-listener policy and the MCP HTTP marker): 2,142 files, 150 occurrences. + /// SPEC-007 PR 4.3 adds the redirect handler and the factory binding filter, neither of which + /// constructs an HTTP client: 2,144 files, 150 occurrences. SPEC-007 PR 4.5 adds + /// IEgressLabelledTool.cs, which constructs no HTTP client: 2,145 files, 150 occurrences. + /// Set far enough below to survive ordinary deletions; a scan that stops reading the tree + /// falls through them. Re-measure and restate when a PR moves them. /// private const int ScannedFilesFloor = 1000; diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressGuardDecisionTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressGuardDecisionTests.cs index 0d8e208d1..8a1628710 100644 --- a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressGuardDecisionTests.cs +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressGuardDecisionTests.cs @@ -723,7 +723,9 @@ public void A_credential_holding_a_delimiter_reaches_no_record_field(string name names.Should().HaveCount(payload.Count).And.Contain("destination").And.Contain("detail"); for (var i = 0; i < payload.Count; i++) { - if (names[i] is "sequence" or "at") + // sequence/at are counters/timestamps. ref is a random 64-bit hex id (see EgressDecision.Ref) — + // not derived from the destination — so a digit-only credential piece can collide by chance. + if (names[i] is "sequence" or "at" or "ref") continue; Convert.ToString(payload[i], CultureInfo.InvariantCulture).Should().NotContainAny( pieces, "the {0} field must carry no piece of the credential in {1}", names[i], name); diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectFloorPinTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectFloorPinTests.cs new file mode 100644 index 000000000..00fdc681f --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectFloorPinTests.cs @@ -0,0 +1,150 @@ +using System.Text.RegularExpressions; +using Ashlar.Core.Application.Paths; +using FluentAssertions; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// +/// SPEC-007 PR 4.5: every production EgressSubject.Enter is pinned with its floor and the method that +/// encloses it. A new production call without a pin fails, and a pin that no longer matches fails. +/// +/// +/// The population is the egress scanner's: src, application, applications, commercial, +/// products, tools and consumer-template, pruning build output, dot directories, nested +/// checkouts and test projects by csproj (). Test +/// Enter calls are not production and are not pinned. The call text is split so this file is not a site. +/// A tripwire, not a proof: an alias, a delegate or reflection is not seen. +/// +[Trait("Category", "Certification")] +public sealed class EgressSubjectFloorPinTests +{ + /// The call as written, split so that this file is not one of the sites it counts. + private const string EnterCall = "EgressSubject" + ".Enter("; + + private const string HighWater = "new HighWaterMark("; + + /// Below this many scanned files the scan is taken to have lost its reach, not to have found nothing. + private const int ScannedFileFloor = 1000; + + private static readonly string[] ProductionRoots = + ["src", "application", "applications", "commercial", "products", "tools", "consumer-template"]; + + /// + /// The only production frame in PR 4.5. The floor is SystemHigh because the self-extend snapshot carries + /// unlabelled carry-over. RunAsync is the overload that runs the cycle; the overloads that await it do not + /// enter. + /// + private static readonly string[] ExpectedSites = + [ + "src/Ashlar.BackgroundAgents.HostRunners/SelfExtendRunnerAdapter.cs | RunAsync | SecurityLabel.SystemHigh | x1", + ]; + + /// One line, so a type cannot swallow the next statement. Keywords are not methods. + private static readonly Regex MethodHeader = new( + @"^[ \t]*(?:(?:public|private|protected|internal)[ \t]+)?(?:(?:static|async|override|virtual|sealed|partial)[ \t]+)*[\w.]+(?:<[^>\n]+>)?[ \t]+(?[A-Za-z_][A-Za-z0-9_]*)[ \t]*\(", + RegexOptions.CultureInvariant); + + private static readonly HashSet NotAMethod = new(StringComparer.Ordinal) + { + "if", "for", "foreach", "while", "switch", "catch", "using", "lock", "return", "var", "else", "throw", "await", "new", "get", "set", + }; + + [Fact] + public void Every_production_Enter_is_pinned_with_its_floor_and_enclosing_method() + { + var root = RepoPathResolver.FindRepoRoot(); + var (sites, scanned) = Sites(root); + + scanned.Should().BeGreaterThanOrEqualTo(ScannedFileFloor, + "an emptied scan must not pass because it found no Enter"); + sites.Should().Equal(ExpectedSites, + "a production Enter that is not pinned, or a pin whose file, method, floor or count drifted, fails"); + } + + private static (List Sites, int Scanned) Sites(string root) + { + var counts = new SortedDictionary(StringComparer.Ordinal); + var scanned = 0; + foreach (var name in ProductionRoots) + { + var directory = Path.Combine(root, name); + if (Directory.Exists(directory)) + scanned += Collect(root, directory, counts); + } + + return (counts.Select(pair => pair.Key + " | x" + pair.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToList(), scanned); + } + + private static int Collect(string root, string directory, SortedDictionary counts) + { + var scanned = 0; + foreach (var file in Directory.EnumerateFiles(directory, "*.cs")) + { + scanned++; + var text = File.ReadAllText(file); + var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + for (var i = text.IndexOf(EnterCall, StringComparison.Ordinal); + i >= 0; + i = text.IndexOf(EnterCall, i + EnterCall.Length, StringComparison.Ordinal)) + { + var key = relative + " | " + EnclosingMethod(text, i) + " | " + Floor(text, i); + counts[key] = counts.TryGetValue(key, out var n) ? n + 1 : 1; + } + } + + foreach (var child in Directory.EnumerateDirectories(directory)) + { + if (!IsPruned(child)) + scanned += Collect(root, child, counts); + } + + return scanned; + } + + private static string EnclosingMethod(string text, int callAt) + { + var prefix = text[..callAt]; + var lines = prefix.Split('\n'); + for (var i = lines.Length - 1; i >= 0; i--) + { + var match = MethodHeader.Match(lines[i]); + if (!match.Success) + continue; + + var name = match.Groups["name"].Value; + if (!NotAMethod.Contains(name)) + return name; + } + + return "(no method)"; + } + + private static string Floor(string text, int callAt) + { + var window = text.Substring(callAt, Math.Min(400, text.Length - callAt)); + var at = window.IndexOf(HighWater, StringComparison.Ordinal); + if (at < 0) + return "(no floor)"; + + var start = at + HighWater.Length; + var end = window.IndexOf(')', start); + if (end < 0) + return "(no floor)"; + + return window[start..end].Trim(); + } + + private static bool IsPruned(string directory) + { + var name = Path.GetFileName(directory); + if (name is "bin" or "obj" || name.StartsWith('.')) + return true; + + var git = Path.Combine(directory, ".git"); + if (File.Exists(git) || Directory.Exists(git)) + return true; + + return EgressGuardConventionTests.IsTestProjectRoot(directory); + } +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectProducerTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectProducerTests.cs new file mode 100644 index 000000000..636ec3352 --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressSubjectProducerTests.cs @@ -0,0 +1,503 @@ +using Ashlar.Abstractions; +using Ashlar.Abstractions.Security; +using Ashlar.Abstractions.Security.Egress; +using Ashlar.AI.Pipeline.Clients; +using Ashlar.AI.Pipeline.Governance; +using Ashlar.AI.Pipeline.Rag; +using Ashlar.BackgroundAgents.Agents; +using Ashlar.BackgroundAgents.DataSensitivity; +using Ashlar.BackgroundAgents.HostRunners; +using Ashlar.BackgroundAgents.RAG; +using Ashlar.Runtime; +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// +/// SPEC-007 PR 4.5, report-only: a subject frame and a labelled read change what the next model call records. +/// Nothing refuses. The send still happens; the record says what would be refused once the switch (4.11) is on. +/// +/// +/// What this composes. The leak facts run the real , the real +/// and the real , inside a real +/// / / path. +/// The self-extend fact runs the real , whose production floor is +/// . The leak facts enter at so a Secret +/// hit is rather than , +/// which is what the production floor records. +/// What this does not compose. Not the full EgressEnforcementLeakTests switch (that is 4.11). +/// Not MeaiBackedModel or AddAshlarMeaiPipeline, not VectorDataRagService, not enforcing mode, +/// not the Bing scenario and not the redirect scenario. The model and the RAG store are fakes. An open read scope +/// still observes only when it ends (the merged 4.4 rule); a within-call egress is a separate fact. +/// +[Trait("Category", "Certification")] +public sealed class EgressSubjectProducerTests +{ + private const string Remote = "https://models.example/v1"; + private static readonly Uri RemoteUri = new(Remote); + + private static readonly SecurityLabel Secret = new(SecurityLevel.Secret); + private static readonly SecurityLabel Internal = new(SecurityLevel.Internal); + + [Fact] + public async Task After_a_Secret_RAG_hit_the_next_model_call_records_the_subject_and_would_refuse_LevelTooLow() + { + var guard = new EgressGuard("full"); + var decisions = await RunRagCycleAsync( + guard, + "agent:leak-secret", + new FixedRag([new VectorSearchResult("doc-1", "the plan", 0.91, "Secret")])); + + decisions.Should().HaveCount(2); + var before = decisions[0]; + before.Current.Should().Be(SecurityLabel.Public, "the model call before the tool has not read the hit yet"); + before.Access.Allowed.Should().BeTrue(); + + var next = decisions[1]; + next.CurrentBasis.Should().Be("subject:agent:leak-secret"); + next.Current.Should().Be(Secret); + next.Mode.Should().Be("report"); + next.Refused.Should().BeFalse("report mode still allows the send"); + next.Access.Allowed.Should().BeFalse(); + next.Access.Reason.Should().Be(AccessDenialReason.LevelTooLow); + } + + [Fact] + public async Task After_an_Internal_RAG_hit_the_next_model_call_is_allowed() + { + var guard = new EgressGuard("full"); + var decisions = await RunRagCycleAsync( + guard, + "agent:leak-internal", + new FixedRag([new VectorSearchResult("doc-2", "the note", 0.8, "Internal")])); + + decisions.Should().HaveCount(2); + var next = decisions[1]; + next.CurrentBasis.Should().Be("subject:agent:leak-internal"); + next.Current.Should().Be(Internal); + next.Mode.Should().Be("report"); + next.Refused.Should().BeFalse(); + next.Access.Allowed.Should().BeTrue(); + next.Access.Reason.Should().Be(AccessDenialReason.None); + } + + [Fact] + public async Task An_unlabelled_tool_is_SystemHigh_on_the_next_model_call() + { + var guard = new EgressGuard("full"); + var model = Scripted(guard, Call("plain"), Done()); + var tools = new CapabilityRegistry(); + tools.Register(new PlainTool()); + + using (EgressSubject.Enter("agent:plain", new HighWaterMark(SecurityLabel.Public))) + { + var cycle = await CycleAsync(model, tools); + cycle.StoppedReason.Should().Be("empty"); + cycle.ToolCallsExecuted.Should().Be(1); + } + + model.Decisions.Should().HaveCount(2); + var next = model.Decisions[1]; + next.CurrentBasis.Should().Be("subject:agent:plain"); + next.Current.Should().Be(SecurityLabel.SystemHigh); + next.Access.Reason.Should().Be(AccessDenialReason.SystemHighData); + next.Refused.Should().BeFalse(); + next.Mode.Should().Be("report"); + } + + [Fact] + public async Task A_tool_that_throws_is_SystemHigh_when_the_scope_ends() + { + var guard = new EgressGuard("full"); + var model = Scripted(guard, Call("boom")); + var tools = new CapabilityRegistry(); + tools.Register(new ThrowingTool()); + + EgressDecision after; + using (EgressSubject.Enter("agent:throws", new HighWaterMark(SecurityLabel.Public))) + { + var cycle = await CycleAsync(model, tools); + cycle.StoppedReason.Should().Be("error"); + after = guard.Evaluate(new EgressRequest(EgressFamilies.ModelMeai, "after-throw", RemoteUri)); + } + + after.CurrentBasis.Should().Be("subject:agent:throws"); + after.Current.Should().Be(SecurityLabel.SystemHigh); + after.Access.Reason.Should().Be(AccessDenialReason.SystemHighData); + after.Refused.Should().BeFalse(); + after.Mode.Should().Be("report"); + } + + [Fact] + public async Task Production_self_extend_records_subject_agent_at_SystemHigh() + { + var repo = Path.Combine(Path.GetTempPath(), "sx-egress-" + Guid.NewGuid().ToString("N")[..12]); + Directory.CreateDirectory(repo); + try + { + var guard = new EgressGuard("full"); + var model = new OneShotModel(guard); + var runner = new SelfExtendRunnerAdapter( + model, + NullLogger.Instance, + NullLoggerFactory.Instance); + + var result = await runner.RunAsync( + repo, + objective: "record the subject", + agentName: "planner", + modelProvider: null, + modelName: null, + agentId: "planner-9"); + + result.Success.Should().BeTrue(result.Summary); + result.StoppedReason.Should().Be("empty"); + model.Decision.Should().NotBeNull(); + var decision = model.Decision!; + decision!.CurrentBasis.Should().Be("subject:agent:planner-9"); + decision.Current.Should().Be(SecurityLabel.SystemHigh, + "the production floor is SystemHigh: the snapshot carries unlabelled carry-over"); + decision.Access.Reason.Should().Be(AccessDenialReason.SystemHighData); + decision.Access.Allowed.Should().BeFalse(); + decision.Refused.Should().BeFalse("report mode still allows the send"); + decision.Mode.Should().Be("report"); + } + finally + { + try { Directory.Delete(repo, recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task An_empty_RAG_search_reports_read_nothing_and_the_next_model_call_stays_at_the_floor() + { + var guard = new EgressGuard("full"); + var decisions = await RunRagCycleAsync(guard, "agent:empty", new FixedRag([])); + + decisions.Should().HaveCount(2); + var next = decisions[1]; + next.CurrentBasis.Should().Be("subject:agent:empty"); + next.Current.Should().Be(SecurityLabel.Public); + next.Access.Allowed.Should().BeTrue(); + next.Access.Reason.Should().Be(AccessDenialReason.None); + next.Refused.Should().BeFalse(); + } + + [Fact] + public async Task An_unrankable_RAG_query_reports_read_nothing() + { + var guard = new EgressGuard("full"); + var decisions = await RunRagCycleAsync(guard, "agent:refused", new FixedRag([], refuse: true)); + + decisions.Should().HaveCount(2); + var next = decisions[1]; + next.Current.Should().Be(SecurityLabel.Public); + next.Access.Allowed.Should().BeTrue(); + next.Access.Reason.Should().Be(AccessDenialReason.None); + } + + [Fact] + public async Task An_egress_during_the_tool_call_is_decided_at_the_pre_read_mark() + { + var guard = new EgressGuard("full"); + var during = new EgressDuringReadTool(guard); + var model = Scripted(guard, Call("during_read"), Done()); + var tools = new CapabilityRegistry(); + tools.Register(during); + + using (EgressSubject.Enter("agent:during", new HighWaterMark(SecurityLabel.Public))) + { + var cycle = await CycleAsync(model, tools); + cycle.StoppedReason.Should().Be("empty"); + cycle.ToolCallsExecuted.Should().Be(1); + } + + during.During.Should().NotBeNull(); + var mid = during.During!; + mid.CurrentBasis.Should().Be("subject:agent:during"); + mid.Current.Should().Be(SecurityLabel.Public, + "an open scope observes only when it ends, so this send is still at the floor"); + mid.Access.Allowed.Should().BeTrue(); + mid.Access.Reason.Should().NotBe(AccessDenialReason.SystemHighData); + mid.Access.Reason.Should().NotBe(AccessDenialReason.LevelTooLow); + + model.Decisions.Should().HaveCount(2); + var next = model.Decisions[1]; + next.Current.Should().Be(Secret); + next.Access.Reason.Should().Be(AccessDenialReason.LevelTooLow); + next.Refused.Should().BeFalse(); + next.Mode.Should().Be("report"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("public")] + [InlineData("INTERNAL")] + [InlineData(" Confidential ")] + [InlineData("secret")] + [InlineData("TopSecret")] + [InlineData("topsecret")] + [InlineData("top-secret")] + [InlineData(" TOP-SECRET ")] + [InlineData("SystemHigh")] + [InlineData("not-a-level")] + [InlineData("Pony")] + public void RAGTool_hit_labels_match_TrustTierOrder_RecordLabel(string? name) + { + var registry = new DataSensitivityRegistry(); + registry.Register(new PonyLevel()); + RAGTool.MapHitLabel(name, registry).Should().Be(TrustTierOrder.RecordLabel(name)); + } + + [Fact] + public async Task A_peer_response_is_observed_as_SystemHigh_and_a_model_response_is_not() + { + var guard = new EgressGuard("full"); + var request = new EgressRequest(EgressFamilies.ModelMeai, "meai:peer:node-7", "meai:peer:node-7"); + var local = new EgressGuardChatClient(new FakeChatClient(), request, guard, "local:ollama"); + var peer = new EgressGuardChatClient(new FakeChatClient(), request, guard, " PEER:node-7 "); + + using (EgressSubject.Enter("agent:peer", new HighWaterMark(SecurityLabel.Public))) + { + await local.GetResponseAsync("hello"); + var afterModel = guard.Evaluate(new EgressRequest(EgressFamilies.ModelMeai, "after-model", RemoteUri)); + afterModel.Current.Should().Be(SecurityLabel.Public); + afterModel.Access.Allowed.Should().BeTrue(); + afterModel.CurrentBasis.Should().Be("subject:agent:peer"); + + await peer.GetResponseAsync("hello"); + var afterPeer = guard.Evaluate(new EgressRequest(EgressFamilies.ModelMeai, "after-peer", RemoteUri)); + afterPeer.CurrentBasis.Should().Be("subject:agent:peer"); + afterPeer.Current.Should().Be(SecurityLabel.SystemHigh); + afterPeer.Access.Reason.Should().Be(AccessDenialReason.SystemHighData); + afterPeer.Refused.Should().BeFalse(); + afterPeer.Mode.Should().Be("report"); + } + } + + [Fact] + public async Task A_streamed_peer_response_is_observed_as_SystemHigh() + { + var guard = new EgressGuard("full"); + var request = new EgressRequest(EgressFamilies.ModelMeai, "meai:peer:node-7", "meai:peer:node-7"); + var peer = new EgressGuardChatClient(new FakeChatClient("streamed"), request, guard, "peer:node-7"); + + using (EgressSubject.Enter("agent:stream", new HighWaterMark(SecurityLabel.Public))) + { + await foreach (var _ in peer.GetStreamingResponseAsync("hello")) + { + } + + var after = guard.Evaluate(new EgressRequest(EgressFamilies.ModelMeai, "after-stream", RemoteUri)); + after.Current.Should().Be(SecurityLabel.SystemHigh); + after.Access.Reason.Should().Be(AccessDenialReason.SystemHighData); + after.Refused.Should().BeFalse(); + } + } + + [Fact] + public void A_peer_call_that_throws_synchronously_is_not_observed() + { + var guard = new EgressGuard("full"); + var request = new EgressRequest(EgressFamilies.ModelMeai, "meai:peer:node-7", "meai:peer:node-7"); + var peer = new EgressGuardChatClient(new SyncDenyClient(), request, guard, "peer:node-7"); + + using (EgressSubject.Enter("agent:denied", new HighWaterMark(SecurityLabel.Public))) + { + Action call = () => _ = peer.GetResponseAsync("hello"); + call.Should().Throw(); + var after = guard.Evaluate(new EgressRequest(EgressFamilies.ModelMeai, "after-deny", RemoteUri)); + after.Current.Should().Be(SecurityLabel.Public); + after.Access.Allowed.Should().BeTrue(); + } + } + + private static async Task> RunRagCycleAsync( + EgressGuard guard, + string subjectId, + IRAGService rag) + { + var model = Scripted(guard, Call("rag_search", """{"query":"plans"}"""), Done()); + var tools = new CapabilityRegistry(); + tools.Register(new RAGTool(rag)); + using (EgressSubject.Enter(subjectId, new HighWaterMark(SecurityLabel.Public))) + { + var cycle = await CycleAsync(model, tools); + cycle.StoppedReason.Should().Be("empty"); + cycle.ToolCallsExecuted.Should().Be(1); + } + + return model.Decisions; + } + + private static async Task CycleAsync(DecidingModel model, CapabilityRegistry tools) + { + var agent = new ToolCallingAgent("leak", model, NullLogger.Instance, maxIterations: 3); + return await agent.RunCycleAsync( + WorldSnapshot.ForRepo("/repo", "/repo/out"), + tools, + new PolicyEngine([new AllowEverything()]), + onRejected: null, + memory: null, + CancellationToken.None); + } + + private static DecidingModel Scripted(EgressGuard guard, params string[] turns) => new(guard, turns); + + private static string Call(string id, string argumentsJson = "{}") => + $$"""{"tool_calls":[{"id":"{{id}}","arguments":{{argumentsJson}}}],"rationale":"scripted"}"""; + + private static string Done() => """{"tool_calls":[],"rationale":"done"}"""; + + private sealed class DecidingModel : IModel + { + private readonly EgressGuard _guard; + private readonly Queue _turns; + + public DecidingModel(EgressGuard guard, IEnumerable turns) + { + _guard = guard; + _turns = new Queue(turns); + } + + public List Decisions { get; } = []; + + public Task CompleteAsync(ModelInput input, CancellationToken ct) + { + Decisions.Add(_guard.Evaluate(new EgressRequest(EgressFamilies.ModelMeai, "producer-twin", RemoteUri))); + var text = _turns.Count > 0 ? _turns.Dequeue() : Done(); + return Task.FromResult(new ModelOutput(text)); + } + } + + private sealed class OneShotModel : IModel + { + private readonly EgressGuard _guard; + + public OneShotModel(EgressGuard guard) => _guard = guard; + + public EgressDecision? Decision { get; private set; } + + public Task CompleteAsync(ModelInput input, CancellationToken ct) + { + Decision = _guard.Evaluate(new EgressRequest(EgressFamilies.ModelMeai, "self-extend-model", RemoteUri)); + return Task.FromResult(new ModelOutput(Done())); + } + } + + private sealed class FixedRag : IRAGService + { + private readonly IReadOnlyList _hits; + private readonly bool _refuse; + + public FixedRag(IReadOnlyList hits, bool refuse = false) + { + _hits = hits; + _refuse = refuse; + } + + public Task> SearchAsync( + string query, int maxResults, double minScore, string? maxSensitivityLevelName, CancellationToken cancellationToken = default) + { + if (_refuse) + throw new ArgumentException("zero magnitude"); + return Task.FromResult(_hits); + } + + public Task IndexAsync(string id, string text, string? sensitivityLevelName, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task RemoveAsync(string id, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task ClearAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task GetDocumentCountAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + + private sealed class PlainTool : ITool + { + public string Id => "plain"; + + public ToolSchema Schema => new(Id, "unlabelled", "{}"); + + public Task InvokeAsync(ToolCall call, WorldSnapshot s, CancellationToken ct) => + Task.FromResult(new ToolResult(new ActionDelta(s.Tick, s.Tick + 1, ["plain"]), new { value = "data" })); + } + + private sealed class ThrowingTool : ITool + { + public string Id => "boom"; + + public ToolSchema Schema => new(Id, "throws", "{}"); + + public Task InvokeAsync(ToolCall call, WorldSnapshot s, CancellationToken ct) => + throw new InvalidOperationException("boom"); + } + + private sealed class EgressDuringReadTool : ITool, IEgressLabelledTool + { + private readonly EgressGuard _guard; + + public EgressDuringReadTool(EgressGuard guard) => _guard = guard; + + public EgressDecision? During { get; private set; } + + public string Id => "during_read"; + + public ToolSchema Schema => new(Id, "egresses before the scope ends", "{}"); + + public Task InvokeAsync(ToolCall call, WorldSnapshot s, CancellationToken ct) + { + During = _guard.Evaluate(new EgressRequest(EgressFamilies.ModelMeai, "during-read", RemoteUri)); + return Task.FromResult(new ToolResult(new ActionDelta(s.Tick, s.Tick + 1, ["during"]), "payload")); + } + + public void ReportRead(ReadScope read, ToolResult result) => read.Report(Secret); + } + + private sealed class PonyLevel : IDataSensitivityLevel + { + public string Value => "Pony"; + public string Display => "Pony"; + public string Description => "a custom level"; + public int SensitivityValue => 3; + public bool AllowsExternalLLM => false; + public bool AllowsWebSearch => false; + public bool RequiresLocalOnly => false; + public bool AllowsNetworkExports => false; + } + + private sealed class SyncDenyClient : IChatClient + { + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("denied"); + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("denied"); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + + private sealed class AllowEverything : IPolicy + { + public bool Approve(ToolCall toolCall, WorldSnapshot s, out string reason) + { + reason = "OK"; + return true; + } + } +}