diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs
new file mode 100644
index 0000000..23000fb
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs
@@ -0,0 +1,45 @@
+using System.CommandLine;
+using System.CommandLine.Parsing;
+
+namespace ScadaLink.CLI.Commands;
+
+///
+/// The scadalink audit command group (Audit Log #23 M8). Provides read access to
+/// the centralized append-only Audit Log via the Bundle B REST endpoints
+/// (GET /api/audit/query, GET /api/audit/export), plus a v1 no-op
+/// verify-chain placeholder for the deferred hash-chain tamper-evidence feature.
+///
+public static class AuditCommands
+{
+ public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var command = new Command("audit") { Description = "Query and export the centralized audit log" };
+
+ command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
+ command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
+ command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
+
+ return command;
+ }
+
+ private static Command BuildQuery(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var cmd = new Command("query") { Description = "Query audit log events" };
+ cmd.SetAction((ParseResult result) => 0);
+ return cmd;
+ }
+
+ private static Command BuildExport(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var cmd = new Command("export") { Description = "Export audit log events to a file" };
+ cmd.SetAction((ParseResult result) => 0);
+ return cmd;
+ }
+
+ private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" };
+ cmd.SetAction((ParseResult result) => 0);
+ return cmd;
+ }
+}
diff --git a/src/ScadaLink.CLI/ManagementHttpClient.cs b/src/ScadaLink.CLI/ManagementHttpClient.cs
index 515bf42..ce8e02b 100644
--- a/src/ScadaLink.CLI/ManagementHttpClient.cs
+++ b/src/ScadaLink.CLI/ManagementHttpClient.cs
@@ -74,6 +74,65 @@ public class ManagementHttpClient : IDisposable
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
}
+ ///
+ /// Issues a plain HTTP GET against a REST endpoint (e.g. the audit
+ /// /api/audit/query endpoint introduced by Audit Log #23 M8) and returns the
+ /// response body. Unlike , this does not wrap the call
+ /// in the POST /management command envelope — the audit endpoints are plain
+ /// REST resources. Authentication (HTTP Basic) and the base address are shared.
+ ///
+ /// Path relative to the base URL, with query string.
+ public async Task SendGetAsync(string relativePath, TimeSpan timeout)
+ {
+ using var cts = new CancellationTokenSource(timeout);
+
+ HttpResponseMessage httpResponse;
+ try
+ {
+ httpResponse = await _httpClient.GetAsync(relativePath, cts.Token);
+ }
+ catch (TaskCanceledException)
+ {
+ return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
+ }
+ catch (HttpRequestException ex)
+ {
+ return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
+ }
+
+ var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
+
+ if (httpResponse.IsSuccessStatusCode)
+ {
+ return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
+ }
+
+ string? error = null;
+ string? code = null;
+ try
+ {
+ using var doc = JsonDocument.Parse(responseBody);
+ error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
+ code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
+ }
+ catch
+ {
+ error = responseBody;
+ }
+
+ return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
+ }
+
+ ///
+ /// Issues a plain HTTP GET and returns the raw
+ /// so the caller can stream the response body without buffering it in memory — used
+ /// by audit export, where the response can be many megabytes. The caller owns
+ /// disposing the returned message. The
+ /// option ensures the body is not pre-buffered.
+ ///
+ public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
+ => await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+
public void Dispose() => _httpClient.Dispose();
}
diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs
index 08da7d7..a79f678 100644
--- a/src/ScadaLink.CLI/Program.cs
+++ b/src/ScadaLink.CLI/Program.cs
@@ -27,6 +27,7 @@ rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOp
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(AuditLogCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
+rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs
new file mode 100644
index 0000000..e75c552
--- /dev/null
+++ b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs
@@ -0,0 +1,61 @@
+using System.CommandLine;
+using ScadaLink.CLI.Commands;
+
+namespace ScadaLink.CLI.Tests.Commands;
+
+///
+/// Scaffold tests for the scadalink audit command group (Audit Log #23 M8-T1).
+/// Verifies the parent command exists with its three subcommands and that every leaf
+/// has an action wired.
+///
+public class AuditCommandsScaffoldTests
+{
+ private static readonly Option Url = new("--url") { Recursive = true };
+ private static readonly Option Username = new("--username") { Recursive = true };
+ private static readonly Option Password = new("--password") { Recursive = true };
+ private static readonly Option Format = CliOptions.CreateFormatOption();
+
+ private static Command BuildAudit()
+ => AuditCommands.Build(Url, Format, Username, Password);
+
+ [Fact]
+ public void Audit_Command_IsNamedAudit()
+ {
+ var audit = BuildAudit();
+ Assert.Equal("audit", audit.Name);
+ Assert.False(string.IsNullOrWhiteSpace(audit.Description));
+ }
+
+ [Fact]
+ public void Audit_HasThreeSubcommands_QueryExportVerifyChain()
+ {
+ var audit = BuildAudit();
+ var names = audit.Subcommands.Select(c => c.Name).OrderBy(n => n).ToArray();
+ Assert.Equal(new[] { "export", "query", "verify-chain" }, names);
+ }
+
+ [Fact]
+ public void Audit_HelpText_ListsAllSubcommands()
+ {
+ var root = new RootCommand();
+ root.Add(BuildAudit());
+
+ var output = new StringWriter();
+ var exit = root.Parse(new[] { "audit", "--help" })
+ .Invoke(new InvocationConfiguration { Output = output });
+
+ Assert.Equal(0, exit);
+ var text = output.ToString();
+ Assert.Contains("query", text);
+ Assert.Contains("export", text);
+ Assert.Contains("verify-chain", text);
+ }
+
+ [Fact]
+ public void Audit_EveryLeafCommand_HasAnAction()
+ {
+ var audit = BuildAudit();
+ Assert.All(audit.Subcommands, sub =>
+ Assert.True(sub.Action != null, $"Leaf command '{sub.Name}' has no action."));
+ }
+}