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
4 changes: 2 additions & 2 deletions .docker/Dockerfile.fleet-host
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Build stage follows global.json (SDK 10). The commercial Fleet.Host project still targets net8.0,
# so the runtime stage stays on the 8.0 ASP.NET Core image (commercial/ is out of scope for the net10 move).
# Build stage follows global.json (SDK 10). The commercial Fleet.Host project targets net10.0, so the
# runtime stage is the 10.0 ASP.NET Core image.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ concurrency:
jobs:
build-core:
name: build-core
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cert-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ concurrency:
jobs:
cert-gate:
name: cert-gate
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs-link-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ concurrency:
jobs:
lychee:
name: lychee (README + docs)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7

Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/full-platform-readiness-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# 3. Dry-run — `ashlar doctor --fix --dry-run --json` + `ashlar bootstrap apply --dry-run --json` + smoke tests
#
# Native runners: ubuntu, macOS, Windows
# Container runners: Ubuntu, Alpine, Debian (Docker on ubuntu-latest)
# Container runners: Ubuntu, Alpine, Debian (Docker on ubuntu-24.04)
#
# Trigger policy (see .github/workflows/README.md, docs/CiGateInventory.md):
# - pull_request: UNFILTERED on purpose. The `changes` job decides in-workflow whether any
Expand Down Expand Up @@ -162,7 +162,7 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
changes:
name: "Changes — core paths touched?"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
Expand Down Expand Up @@ -344,13 +344,13 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
- os: ubuntu-24.04
label: Linux
setup_cmd: bash scripts/setup/setup.sh
- os: macos-latest
- os: macos-26
label: macOS
setup_cmd: bash scripts/setup/setup.sh
- os: windows-latest
- os: windows-2025-vs2026
label: Windows
setup_cmd: pwsh scripts/setup/setup.ps1 -Mode

Expand Down Expand Up @@ -520,7 +520,7 @@ jobs:
name: "${{ matrix.label }} (container) — setup · discover · dry-run"
needs: changes
if: needs.changes.outputs.run_heavy == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -620,7 +620,7 @@ jobs:
name: "Docker CLI image — build · smoke · discover"
needs: changes
if: needs.changes.outputs.run_heavy == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down Expand Up @@ -653,7 +653,7 @@ jobs:
# Production images are never built on PRs (kept from before the PR trigger existed);
# on push/schedule/dispatch they additionally follow the shared run_heavy decision.
if: github.event_name != 'pull_request' && github.event_name != 'merge_group' && needs.changes.outputs.run_heavy == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
# Builds take ~1 minute each (layer cache); the smoke below is bounded at 2 minutes.
# The previous 90-minute budget only ever measured how long a hung smoke could hang.
timeout-minutes: 20
Expand Down Expand Up @@ -707,7 +707,7 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
readiness-summary:
name: "Readiness summary"
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: [changes, native-platform, container-platform, docker-cli-image, docker-all-images]
if: always()
# `verdict` distinguishes "every platform passed" from "no platform ran". Both exit 0 - this is a
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/shell-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ on:
jobs:
shell-lint:
name: shell-lint
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
Expand All @@ -51,7 +51,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
# ubuntu-latest ships shellcheck; install only if the image ever drops it.
# The ubuntu-24.04 image ships shellcheck; install only if the image ever drops it.
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq shellcheck
Expand Down
32 changes: 18 additions & 14 deletions CHANGELOG.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,31 +1,55 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Ashlar.BackgroundAgents.DataSensitivity;
using Ashlar.BackgroundAgents.RAG;

namespace Ashlar.CLI.Commands.BackgroundAgent;

/// <summary>
/// CLI command handler for RAG operations (index, search, stats, clear).
/// </summary>
/// <remarks>
/// <para><b>Both commands take the sensitivity decision out of the defaults.</b></para>
/// <para><c>rag index</c> REFUSES to run without <c>--sensitivity</c>. The alternative was to
/// default an omitted label to the top level, and that was rejected: a knowledge base indexed that
/// way is invisible to every search that does not pass the top clearance, so the omission would
/// surface later as "search finds nothing" with no pointer back to its cause. It used to default to
/// Public, which published every file. A refusal at index time names the missing decision at the
/// moment it is being made. (The library layer underneath still treats an omitted label as the
/// most restrictive level, for programmatic callers.)</para>
/// <para><c>rag search</c> without <c>--max-sensitivity</c> searches at the FLOOR (Public) and says
/// so; it used to search at TopSecret, returning the whole corpus. A <c>--max-sensitivity</c> that
/// names no known level is refused rather than floored, so a misspelt clearance cannot read as
/// "nothing matched".</para>
/// </remarks>
public class RAGCommand
{
private readonly IRAGService _ragService;
private readonly IKnowledgeBaseIndexer _indexer;
private readonly ILogger<RAGCommand> _logger;
private readonly IDataSensitivityRegistry _sensitivityRegistry;

/// <summary>Creates a new RAGCommand instance.</summary>
/// <param name="ragService">RAG service.</param>
/// <param name="indexer">Knowledge base indexer.</param>
/// <param name="logger">Logger.</param>
/// <param name="sensitivityRegistry">Registry used to recognise level names; the five primitive
/// levels when null.</param>
public RAGCommand(
IRAGService ragService,
IKnowledgeBaseIndexer indexer,
ILogger<RAGCommand> logger)
ILogger<RAGCommand> logger,
IDataSensitivityRegistry? sensitivityRegistry = null)
{
_ragService = ragService ?? throw new ArgumentNullException(nameof(ragService));
_indexer = indexer ?? throw new ArgumentNullException(nameof(indexer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_sensitivityRegistry = sensitivityRegistry ?? new DataSensitivityRegistry();
}

/// <summary>
/// Index files or directories into the RAG store.
/// Index files or directories into the RAG store. <paramref name="sensitivity"/> is REQUIRED:
/// without it nothing is indexed and the exit code is 1.
/// </summary>
public async Task<int> IndexAsync(IEnumerable<string> paths, string? sensitivity, bool formatJson, CancellationToken ct = default)
{
Expand All @@ -40,6 +64,23 @@ public async Task<int> IndexAsync(IEnumerable<string> paths, string? sensitivity
Console.Error.WriteLine("At least one path required");
return 1;
}

if (string.IsNullOrWhiteSpace(sensitivity))
{
var error = "--sensitivity is required: choose the level these documents are cleared at ("
+ KnownLevels() + "). Nothing was indexed. Unlabelled documents are no longer "
+ "stored as Public; the label has to be a deliberate choice.";
if (formatJson)
Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = false, error }));
else
Console.Error.WriteLine(error);
return 1;
}

if (_sensitivityRegistry.GetByName(sensitivity) is null)
{
return UnknownLevel("--sensitivity", sensitivity, formatJson);
}
var count = await _indexer.IndexDocumentsAsync(pathList, sensitivity, ct).ConfigureAwait(false);
if (formatJson)
Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = true, paths = pathList, indexedCount = count }));
Expand All @@ -59,7 +100,8 @@ public async Task<int> IndexAsync(IEnumerable<string> paths, string? sensitivity
}

/// <summary>
/// Search the RAG store.
/// Search the RAG store. An omitted <paramref name="maxSensitivity"/> searches at the floor
/// (Public); one that names no known level is refused with exit code 1.
/// </summary>
public async Task<int> SearchAsync(string query, int maxResults, double minScore, string? maxSensitivity, bool formatJson, CancellationToken ct = default)
{
Expand All @@ -73,16 +115,33 @@ public async Task<int> SearchAsync(string query, int maxResults, double minScore
Console.Error.WriteLine("Query required");
return 1;
}
string clearance;
if (string.IsNullOrWhiteSpace(maxSensitivity))
{
clearance = _sensitivityRegistry.Floor().Value;
}
else if (_sensitivityRegistry.GetByName(maxSensitivity) is { } level)
{
clearance = level.Value;
}
else
{
return UnknownLevel("--max-sensitivity", maxSensitivity, formatJson);
}

var results = await _ragService.SearchAsync(query, maxResults, minScore, maxSensitivity, ct).ConfigureAwait(false);
if (formatJson)
{
var items = results.Select(r => new { r.Id, r.Text, r.Score, r.SensitivityLevelName }).ToList();
var options = new JsonSerializerOptions { WriteIndented = true };
Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = true, query, results = items }, options));
Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = true, query, clearance, results = items }, options));
}
else
{
Console.Out.WriteLine($"RAG Search: \"{query}\"");
Console.Out.WriteLine(string.IsNullOrWhiteSpace(maxSensitivity)
? $" Clearance: {clearance} (the floor: --max-sensitivity not given)"
: $" Clearance: {clearance}");
foreach (var r in results)
{
Console.Out.WriteLine($" Score: {r.Score:F3} | {r.SensitivityLevelName ?? "-"} | {r.Id}");
Expand Down Expand Up @@ -152,6 +211,19 @@ public async Task<int> ClearAsync(bool formatJson, CancellationToken ct = defaul
}
}

private string KnownLevels() =>
string.Join(", ", _sensitivityRegistry.GetAll().Select(l => l.Value));

private int UnknownLevel(string option, string value, bool formatJson)
{
var error = $"{option} '{value}' is not a known sensitivity level (known: {KnownLevels()}).";
if (formatJson)
Console.Out.WriteLine(JsonSerializer.Serialize(new { ok = false, error }));
else
Console.Error.WriteLine(error);
return 1;
}

private static string Truncate(string text, int maxLen)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
Expand Down
69 changes: 45 additions & 24 deletions application/src/Ashlar.CLI/Commands/ImproveCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,33 +87,31 @@ public sealed class ImproveCommand : Command
});
}

private static async Task ExecuteAsync(string? path, bool dryRun, string autonomy = "supervised", bool yes = false, bool skipRegression = false, string? storePathOverride = null, bool self = false, string? holdoutFilter = null, bool fromObservation = false, int observationDays = 7)
/// <summary>
/// Registers the provider-factory chain the improve loop uses, sanitized when
/// <paramref name="trustEnabled"/>.
/// </summary>
/// <remarks>
/// With trust enabled this used to register <c>CloudSanitizationProxy</c> by type alone. Its
/// constructor takes every dependency as optional, so DI built it with NO content filter, NO
/// taxonomy and NO audit log, and the proxy then passed every prompt through unaudited: the
/// trust switch wrapped the provider in a sanitizer that sanitized nothing. It now takes the
/// same trust composition the kernel uses (<c>AddTrustServices</c>, provider registration
/// skipped because this method builds that chain itself), which supplies the filter, the
/// taxonomy and the audit log. The proxy also refuses outright when it has no filter, so a
/// future registration that drops the filter blocks cloud calls instead of silently allowing them.
/// </remarks>
internal static void RegisterProviderFactories(IServiceCollection serviceCollection, bool trustEnabled)
{
var repoRoot = RepoPathResolver.FindRepoRoot();
var storePath = !string.IsNullOrWhiteSpace(storePathOverride)
? Path.Combine(Path.GetFullPath(storePathOverride), "ashlar-patterns.db")
: Path.Combine(RepoPathResolver.ResolveStateDirectory(repoRoot), "ashlar-patterns.db");
var targetPath = path ?? RepoPathResolver.FindBlock1ObservationPath(repoRoot);

var trustEnabled = string.Equals(
Environment.GetEnvironmentVariable("ASHLAR_TRUST_ENABLED"), "1",
StringComparison.OrdinalIgnoreCase);

var serviceCollection = new ServiceCollection()
.AddLogging(b => b.AddConsole())
.AddCodeAnalyzers()
.AddAdaptationInfrastructure(storePath)
.AddAdaptationBricks(typeof(OWASPScannerBrick))
.AddSelfContextInfrastructure(storePath)
.AddSharedAdaptationCache();

serviceCollection.AddSingleton<Ashlar.Infrastructure.Execution.ProviderFactory>();

if (trustEnabled)
{
serviceCollection.AddSingleton<Ashlar.BackgroundAgents.Trust.ICloudSanitizationProxy,
Ashlar.BackgroundAgents.Trust.CloudSanitizationProxy>();

Ashlar.BackgroundAgents.ServiceCollectionExtensions.AddTrustServices(
serviceCollection,
useSanitizingProviderFactory: false,
skipProviderRegistration: true);

// Register ONE SanitizingProviderFactory wrapping Infrastructure ProviderFactory
// Infrastructure chain stays Infrastructure; adapt at edge for Application port
serviceCollection.AddSingleton<Ashlar.BackgroundAgents.Trust.SanitizingProviderFactory>(sp =>
Expand All @@ -124,7 +122,7 @@ private static async Task ExecuteAsync(string? path, bool dryRun, string autonom
sp.GetRequiredService<Ashlar.BackgroundAgents.Trust.ICloudSanitizationProxy>(),
sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<Ashlar.BackgroundAgents.Trust.SanitizingProviderFactory>>());
});

// Dual-register: SAME Sanitizing instance for both Infra and App ports
serviceCollection.AddSingleton<Ashlar.Infrastructure.Execution.IProviderFactory>(
sp => sp.GetRequiredService<Ashlar.BackgroundAgents.Trust.SanitizingProviderFactory>());
Expand All @@ -142,6 +140,29 @@ private static async Task ExecuteAsync(string? path, bool dryRun, string autonom
return new Ashlar.Infrastructure.Adapters.ProviderFactoryAdapter(infraFactory);
});
}
}

private static async Task ExecuteAsync(string? path, bool dryRun, string autonomy = "supervised", bool yes = false, bool skipRegression = false, string? storePathOverride = null, bool self = false, string? holdoutFilter = null, bool fromObservation = false, int observationDays = 7)
{
var repoRoot = RepoPathResolver.FindRepoRoot();
var storePath = !string.IsNullOrWhiteSpace(storePathOverride)
? Path.Combine(Path.GetFullPath(storePathOverride), "ashlar-patterns.db")
: Path.Combine(RepoPathResolver.ResolveStateDirectory(repoRoot), "ashlar-patterns.db");
var targetPath = path ?? RepoPathResolver.FindBlock1ObservationPath(repoRoot);

var trustEnabled = string.Equals(
Environment.GetEnvironmentVariable("ASHLAR_TRUST_ENABLED"), "1",
StringComparison.OrdinalIgnoreCase);

var serviceCollection = new ServiceCollection()
.AddLogging(b => b.AddConsole())
.AddCodeAnalyzers()
.AddAdaptationInfrastructure(storePath)
.AddAdaptationBricks(typeof(OWASPScannerBrick))
.AddSelfContextInfrastructure(storePath)
.AddSharedAdaptationCache();

RegisterProviderFactories(serviceCollection, trustEnabled);

if (self)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ private static Command BuildBackgroundAgentCommand(Option<bool> jsonOpt)
// ashlar background-agent rag
var ragCmd = new Command("rag", "RAG (Retrieval Augmented Generation) operations");
var ragIndexPathsOpt = new Option<string[]>("--paths", "Paths to index (files or directories)") { IsRequired = true, AllowMultipleArgumentsPerToken = true };
var ragIndexSensOpt = new Option<string?>("--sensitivity", "Default sensitivity level for indexed documents");
var ragIndexSensOpt = new Option<string?>("--sensitivity", "Sensitivity level for the indexed documents (required; e.g. Public, Internal, Confidential, Secret, TopSecret). Omitting it indexes nothing");
var ragIndexCmd = new Command("index", "Index paths into RAG store") { ragIndexPathsOpt, ragIndexSensOpt };
ragIndexCmd.SetHandler(
/// <summary>Async.</summary>
Expand All @@ -664,7 +664,7 @@ private static Command BuildBackgroundAgentCommand(Option<bool> jsonOpt)
ragSearchQueryOpt,
new Option<int>("--max-results", () => 5, "Max results"),
new Option<double>("--min-score", () => 0.0, "Min similarity score"),
new Option<string?>("--max-sensitivity", "Max sensitivity level for results")
new Option<string?>("--max-sensitivity", "Max sensitivity level for results (your clearance). Omitted means the floor, Public; an unknown level is refused")
};
ragSearchCmd.SetHandler(
/// <summary>Async.</summary>
Expand Down
Loading
Loading