Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` 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:<id>` 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
Expand Down
15 changes: 8 additions & 7 deletions ci/cert-gate-assertions.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/EgressInventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` 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:<id>` 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 |
Expand Down
18 changes: 16 additions & 2 deletions docs/knowledge-graph.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -5203,13 +5203,27 @@
"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",
"namespace": "Ashlar.Tests.Infrastructure.Tests.Certification",
"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",
Expand Down
4 changes: 2 additions & 2 deletions docs/knowledge-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion docs/specs/SPEC-007-security-labels-and-reference-monitor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<id>` 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:<id>` 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 (<reason>)`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
{
Expand Down
63 changes: 60 additions & 3 deletions src/Ashlar.AI.Pipeline/Governance/EgressGuardChatClient.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Ashlar.Abstractions.Security;
using Ashlar.Abstractions.Security.Egress;
using Microsoft.Extensions.AI;

Expand All @@ -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.</para>
/// <para>A <see langword="null"/> request (the in-process LLamaSharp client under <c>local:onnx</c>) delegates without
/// deciding.</para>
/// <para><b>Peer responses (SPEC-007 PR 4.5).</b> A response from a target whose key starts with <c>peer:</c>,
/// in any case, is another agent's output. It is unlabelled until PR 5, so after the response returns — or faults —
/// the layer observes <see cref="SecurityLabel.SystemHigh"/>. The decision for this call was already recorded, at
/// the mark from before the response. Model endpoints (<c>local:</c>, <c>cloud:</c>) 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.</para>
/// <para><b>Ollama cloud models</b> (SPEC-007 PR 4.1). A model whose id ends in <c>-cloud</c> or <c>:cloud</c>, 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 <c>https://ollama.com</c>, under the target's site, whatever the target's own
Expand All @@ -29,17 +36,33 @@ public sealed class EgressGuardChatClient : DelegatingChatClient

private readonly IEgressGuard _guard;
private readonly string? _defaultModelId;
private readonly string? _targetKey;

/// <summary>Creates the guard layer around an inner client.</summary>
/// <summary>Creates the guard layer around an inner client. No target key, so a response is not a read.</summary>
/// <param name="innerClient">The client this layer delegates to.</param>
/// <param name="request">What each call is reported as, or <see langword="null"/> for an in-process target.</param>
/// <param name="guard">The guard; <see langword="null"/> means <see cref="EgressGuard.ProcessDefault"/>.</param>
public EgressGuardChatClient(IChatClient innerClient, EgressRequest? request, IEgressGuard? guard = null)
: this(innerClient, request, guard, targetKey: null)
{
}

/// <summary>Creates the guard layer around an inner client for a governed target key.</summary>
/// <param name="innerClient">The client this layer delegates to.</param>
/// <param name="request">What each call is reported as, or <see langword="null"/> for an in-process target.</param>
/// <param name="guard">The guard; <see langword="null"/> means <see cref="EgressGuard.ProcessDefault"/>.</param>
/// <param name="targetKey">
/// The governed chat target. A key that starts with <c>peer:</c> observes <see cref="SecurityLabel.SystemHigh"/>
/// after a response. Model endpoints are not reads. Required, so this overload does not collide with the
/// three-argument constructor.
/// </param>
public EgressGuardChatClient(IChatClient innerClient, EgressRequest? request, IEgressGuard? guard, string? targetKey)
: base(innerClient)
{
Request = request;
_guard = guard ?? EgressGuard.ProcessDefault;
_defaultModelId = DefaultModelIdOf(innerClient);
_targetKey = targetKey;
}

/// <summary>
Expand All @@ -55,7 +78,8 @@ public override Task<ChatResponse> GetResponseAsync(
CancellationToken cancellationToken = default)
{
Decide(options);
return base.GetResponseAsync(messages, options, cancellationToken);
var pending = base.GetResponseAsync(messages, options, cancellationToken);
return ObservesAgentBackedResponse ? ObserveAfterAsync(pending) : pending;
}

/// <inheritdoc />
Expand All @@ -65,7 +89,40 @@ public override IAsyncEnumerable<ChatResponseUpdate> 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<ChatResponse> ObserveAfterAsync(Task<ChatResponse> pending)
{
try
{
return await pending.ConfigureAwait(false);
}
finally
{
EgressSubject.Observe(SecurityLabel.SystemHigh);
}
}

private static async IAsyncEnumerable<ChatResponseUpdate> ObserveStream(IAsyncEnumerable<ChatResponseUpdate> pending)
{
try
{
await foreach (var update in pending.ConfigureAwait(false))
yield return update;
}
finally
{
EgressSubject.Observe(SecurityLabel.SystemHigh);
}
}

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions src/Ashlar.Abstractions/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/Ashlar.Abstractions/Security/Egress/IEgressLabelledTool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Ashlar.Abstractions.Security.Egress;

/// <summary>
/// A tool that labels everything its result carries. The agent that holds the read scope accepts
/// <see cref="ReportRead"/> only from a tool that implements this. Any other tool's read stays unreported
/// and is observed as <see cref="SecurityLabel.SystemHigh"/> when the scope ends.
/// </summary>
/// <remarks>
/// 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 <c>RAGTool</c>.
/// </remarks>
public interface IEgressLabelledTool
{
/// <summary>
/// Reports the label of everything <paramref name="result"/> carries into <paramref name="read"/>, before
/// the holder completes the scope. A read that returned nothing reports <see cref="SecurityLabel.Public"/>.
/// </summary>
/// <param name="read">The scope the holder began around this tool call. Still open.</param>
/// <param name="result">What the tool returned.</param>
void ReportRead(ReadScope read, ToolResult result);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -243,6 +245,11 @@ public async Task<SelfExtendRunResult> 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)
Expand Down Expand Up @@ -301,6 +308,7 @@ public async Task<SelfExtendRunResult> RunAsync(
cycle.Iterations,
cycle.StoppedReason,
gateOutcome);
}
}
catch (Exception ex)
{
Expand Down
Loading
Loading