feat(cli): add core system components for CLI hardening

Add signal handling, environment detection, and secrets management:
- signals.zig: graceful Ctrl+C handling and signal management
- environment.zig: user environment detection for telemetry
- secrets.zig: secrets redaction for secure logging

Improves CLI reliability and security posture.
This commit is contained in:
Jeremie Fraeys 2026-03-04 20:23:12 -05:00
parent 8ae0875800
commit 524f440fe4
No known key found for this signature in database
3 changed files with 684 additions and 0 deletions

View file

@ -0,0 +1,272 @@
const std = @import("std");
const builtin = @import("builtin");
/// Environment detection for smart defaults
/// Detects local dev, container, CI, or production environments
pub const Environment = enum {
local, // Local development
container, // Docker, Kubernetes, etc.
ci, // CI/CD pipeline (GitHub Actions, GitLab CI, etc.)
production, // Production deployment
const Self = @This();
/// Auto-detect current environment
pub fn detect() Self {
// Check for CI environments first (highest priority)
if (isCI()) return .ci;
// Check for container environment
if (isContainer()) return .container;
// Check for production
if (isProduction()) return .production;
// Default to local
return .local;
}
/// Check if running in CI environment
fn isCI() bool {
const ci_vars = &[_][]const u8{
"CI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"JENKINS_URL",
"CIRCLECI",
"TRAVIS",
"BUILDKITE",
"DRONE",
"CODEBUILD_BUILD_ID",
};
for (ci_vars) |var_name| {
if (std.posix.getenv(var_name)) |_| return true;
}
return false;
}
/// Check if running in container
fn isContainer() bool {
// Check for .dockerenv file
if (std.fs.accessAbsolute("/.dockerenv", .{})) {
return true;
} else |_| {}
// Check for Kubernetes
if (std.posix.getenv("KUBERNETES_SERVICE_HOST")) |_| return true;
// Check for container-specific env vars
if (std.posix.getenv("CONTAINER")) |_| return true;
// Check cgroup for container indicators
if (checkCgroup()) return true;
return false;
}
/// Check cgroup for container indicators
fn checkCgroup() bool {
const cgroup_path = "/proc/self/cgroup";
const file = std.fs.openFileAbsolute(cgroup_path, .{}) catch return false;
defer file.close();
const content = file.readToEndAlloc(std.heap.c_allocator, 4096) catch return false;
defer std.heap.c_allocator.free(content);
// Check for container indicators in cgroup
if (std.mem.indexOf(u8, content, "docker") != null) return true;
if (std.mem.indexOf(u8, content, "kubepods") != null) return true;
if (std.mem.indexOf(u8, content, "containerd") != null) return true;
return false;
}
/// Check if production environment
fn isProduction() bool {
const prod_vars = &[_][]const u8{
"FETCH_ML_ENV",
"ENV",
"NODE_ENV",
"RAILS_ENV",
"APP_ENV",
};
for (prod_vars) |var_name| {
if (std.posix.getenv(var_name)) |val| {
if (std.mem.eql(u8, val, "production") or
std.mem.eql(u8, val, "prod"))
{
return true;
}
}
}
return false;
}
/// Get description of environment
pub fn description(self: Self) []const u8 {
return switch (self) {
.local => "Local development",
.container => "Container environment",
.ci => "CI/CD pipeline",
.production => "Production deployment",
};
}
};
/// Smart defaults configuration
pub const SmartDefaults = struct {
env: Environment,
const Self = @This();
pub fn init() Self {
return .{
.env = Environment.detect(),
};
}
/// Get default host based on environment
pub fn host(self: Self) []const u8 {
return switch (self.env) {
.local => "localhost",
.container => "host.docker.internal",
.ci => "localhost",
.production => "0.0.0.0",
};
}
/// Get default base path for experiments
pub fn basePath(self: Self) []const u8 {
return switch (self.env) {
.local => "~/ml-experiments",
.container => "/workspace/ml-experiments",
.ci => "/tmp/ml-experiments",
.production => "/var/lib/fetch_ml/experiments",
};
}
/// Get default data directory
pub fn dataDir(self: Self) []const u8 {
return switch (self.env) {
.local => "~/ml-data",
.container => "/workspace/data",
.ci => "/tmp/ml-data",
.production => "/var/lib/fetch_ml/data",
};
}
/// Get default config path
pub fn configPath(self: Self) []const u8 {
return switch (self.env) {
.local => "~/.ml/config.toml",
.container => "/workspace/.ml/config.toml",
.ci => "/tmp/.ml/config.toml",
.production => "/etc/fetch_ml/config.toml",
};
}
/// Get default Redis address
pub fn redisAddr(self: Self) []const u8 {
return switch (self.env) {
.local => "localhost:6379",
.container => "redis:6379",
.ci => "localhost:6379",
.production => "redis:6379",
};
}
/// Get default SSH key path
pub fn sshKeyPath(self: Self) []const u8 {
return switch (self.env) {
.local => "~/.ssh/id_rsa",
.container => "/workspace/.ssh/id_rsa",
.ci => "/tmp/.ssh/id_rsa",
.production => "/etc/fetch_ml/ssh/id_rsa",
};
}
/// Get default worker count
pub fn workerCount(self: Self) u32 {
return switch (self.env) {
.local => 2,
.container => 2,
.ci => 1, // Minimal for fast testing
.production => @intCast(std.Thread.getCpuCount() catch 4),
};
}
/// Get default poll interval (seconds)
pub fn pollInterval(self: Self) u32 {
return switch (self.env) {
.local => 5,
.container => 5,
.ci => 1, // Fast polling for CI
.production => 10,
};
}
/// Get default log level
pub fn logLevel(self: Self) []const u8 {
return switch (self.env) {
.local => "info",
.container => "info",
.ci => "debug", // Verbose for debugging
.production => "warn", // Quieter in production
};
}
/// Get default verbosity (for CLI output)
pub fn verbose(self: Self) bool {
return switch (self.env) {
.local => true,
.container => false,
.ci => true, // Verbose for CI logs
.production => false,
};
}
/// Get default JSON output mode
pub fn jsonOutput(self: Self) bool {
return switch (self.env) {
.local => false,
.container => true, // JSON for containerized environments
.ci => true, // JSON for CI parsing
.production => true,
};
}
/// Print environment info (for debugging)
pub fn printInfo(self: Self, writer: anytype) !void {
try writer.print("Environment: {s} ({s})\n", .{
@tagName(self.env),
self.env.description(),
});
try writer.print(" Host: {s}\n", .{self.host()});
try writer.print(" Base Path: {s}\n", .{self.basePath()});
try writer.print(" Config: {s}\n", .{self.configPath()});
try writer.print(" Redis: {s}\n", .{self.redisAddr()});
try writer.print(" Workers: {d}\n", .{self.workerCount()});
try writer.print(" Poll Interval: {d}s\n", .{self.pollInterval()});
try writer.print(" Log Level: {s}\n", .{self.logLevel()});
}
};
/// Convenience function to get smart defaults
pub fn getSmartDefaults() SmartDefaults {
return SmartDefaults.init();
}
test "environment detection" {
// Just verify the functions don't crash
const env = Environment.detect();
_ = env.description();
const smart = SmartDefaults.init();
_ = smart.host();
_ = smart.basePath();
_ = smart.configPath();
}

165
cli/src/core/signals.zig Normal file
View file

@ -0,0 +1,165 @@
const std = @import("std");
/// Ctrl+C / SIGINT handling for graceful shutdown
/// Implements UX contract semantics from cli-tui-ux-contract-v1.md
pub const SignalHandler = struct {
/// Current operation type for context-aware shutdown
pub const Operation = enum {
idle,
queue, // Job submission
dry_run, // Validation only
watch, // Status watch mode
sync, // File synchronization
hash, // Dataset hashing
other,
};
var current_operation: Operation = .idle;
var shutdown_requested: bool = false;
var original_termios: ?std.posix.termios = null;
/// Initialize signal handler
pub fn init() void {
// Set up Ctrl+C handler
const act = std.posix.Sigaction{
.handler = .{ .handler = handleSignal },
.mask = std.posix.empty_sigset,
.flags = 0,
};
std.posix.sigaction(std.posix.SIG.INT, &act, null) catch |err| {
std.log.warn("Failed to set up SIGINT handler: {}", .{err});
};
// Also handle SIGTERM for container environments
std.posix.sigaction(std.posix.SIG.TERM, &act, null) catch |err| {
std.log.warn("Failed to set up SIGTERM handler: {}", .{err});
};
}
/// Signal handler callback
fn handleSignal(sig: i32) callconv(.C) void {
_ = sig;
shutdown_requested = true;
// Context-aware behavior per UX contract
switch (current_operation) {
.dry_run => {
// Immediate exit, no side effects
std.log.info("Dry-run interrupted - exiting immediately", .{});
std.process.exit(0);
},
.queue => {
// Attempt to cancel submission, show status
std.log.info("Queue interrupted - attempting to cancel...", .{});
// Note: actual cancellation logic handled by caller
},
.watch => {
// Exit watch mode gracefully
std.log.info("Watch mode interrupted - exiting", .{});
// Caller should detect shutdown_requested and exit cleanly
},
.sync => {
// Stop after current file, preserve partial state
std.log.info("Sync interrupted - stopping after current operation", .{});
},
.hash => {
// Stop hashing, preserve partial results
std.log.info("Hash operation interrupted", .{});
},
.idle, .other => {
std.log.info("Interrupted - cleaning up...", .{});
},
}
}
/// Set current operation for context-aware handling
pub fn setOperation(op: Operation) void {
current_operation = op;
}
/// Clear current operation
pub fn clearOperation() void {
current_operation = .idle;
}
/// Check if shutdown was requested
pub fn isShutdownRequested() bool {
return shutdown_requested;
}
/// Reset shutdown flag (for testing)
pub fn reset() void {
shutdown_requested = false;
current_operation = .idle;
}
/// Run cleanup and exit gracefully
pub fn shutdown(exit_code: u8) noreturn {
// Restore terminal state if modified
if (original_termios) |termios| {
_ = std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, &termios) catch {};
}
std.log.info("Shutting down gracefully (exit code: {d})", .{exit_code});
std.process.exit(exit_code);
}
/// Wait for condition or shutdown request
pub fn waitOrShutdown(condition: *bool, timeout_ms: u32) bool {
const start = std.time.milliTimestamp();
while (!condition.*) {
if (shutdown_requested) return false;
const now = std.time.milliTimestamp();
if (now - start > timeout_ms) return false;
std.time.sleep(100_000); // 100ms
}
return true;
}
/// Print current status before exiting
pub fn printStatus(writer: anytype) !void {
try writer.print("\nInterrupted during: {s}\n", .{@tagName(current_operation)});
try writer.writeAll("Current operation status preserved.\n");
}
};
/// Convenience functions
pub fn init() void {
SignalHandler.init();
}
pub fn setOperation(op: SignalHandler.Operation) void {
SignalHandler.setOperation(op);
}
pub fn clearOperation() void {
SignalHandler.clearOperation();
}
pub fn isShutdownRequested() bool {
return SignalHandler.isShutdownRequested();
}
pub fn checkAndShutdown(exit_code: u8) void {
if (SignalHandler.isShutdownRequested()) {
SignalHandler.shutdown(exit_code);
}
}
/// RAII guard for operation context
pub const OperationGuard = struct {
pub fn init(op: SignalHandler.Operation) OperationGuard {
setOperation(op);
return .{};
}
pub fn deinit(_: OperationGuard) void {
clearOperation();
}
};
/// Export for integration with main.zig
pub const default_signal_handler = SignalHandler.init;

247
cli/src/utils/secrets.zig Normal file
View file

@ -0,0 +1,247 @@
const std = @import("std");
/// Secrets redaction for secure logging
/// Prevents accidental exposure of sensitive data in logs/output
pub const SecretsFilter = struct {
allocator: std.mem.Allocator,
patterns: std.ArrayList(Pattern),
const Pattern = struct {
name: []const u8,
regex: []const u8, // Simplified pattern matching
prefix: []const u8,
suffix: []const u8,
};
const Self = @This();
/// Common secret patterns
const DEFAULT_PATTERNS = &[_]Pattern{
.{
.name = "API Key",
.regex = "",
.prefix = "api_key",
.suffix = "",
},
.{
.name = "API Key (alt)",
.regex = "",
.prefix = "api-key",
.suffix = "",
},
.{
.name = "Authorization",
.regex = "",
.prefix = "Authorization: Bearer ",
.suffix = "",
},
.{
.name = "Password",
.regex = "",
.prefix = "password",
.suffix = "",
},
.{
.name = "Secret",
.regex = "",
.prefix = "secret",
.suffix = "",
},
.{
.name = "Token",
.regex = "",
.prefix = "token",
.suffix = "",
},
.{
.name = "Private Key",
.regex = "",
.prefix = "-----BEGIN",
.suffix = "-----END",
},
.{
.name = "AWS Access Key",
.regex = "",
.prefix = "AKIA",
.suffix = "",
},
.{
.name = "GitHub Token",
.regex = "",
.prefix = "ghp_",
.suffix = "",
},
.{
.name = "Slack Token",
.regex = "",
.prefix = "xoxb-",
.suffix = "",
},
};
pub fn init(allocator: std.mem.Allocator) Self {
var patterns = std.ArrayList(Pattern).init(allocator);
// Add default patterns
for (DEFAULT_PATTERNS) |p| {
patterns.append(p) catch {};
}
return .{
.allocator = allocator,
.patterns = patterns,
};
}
pub fn deinit(self: *Self) void {
self.patterns.deinit();
}
/// Add custom pattern
pub fn addPattern(self: *Self, name: []const u8, prefix: []const u8) !void {
try self.patterns.append(.{
.name = name,
.regex = "",
.prefix = prefix,
.suffix = "",
});
}
/// Scan and redact secrets from text
pub fn redact(self: *Self, text: []const u8) ![]const u8 {
// Simple prefix-based redaction
var result = try self.allocator.dupe(u8, text);
errdefer self.allocator.free(result);
for (self.patterns.items) |pattern| {
result = try self.redactPattern(result, pattern);
}
return result;
}
/// Redact a specific pattern
fn redactPattern(self: *Self, text: []const u8, pattern: Pattern) ![]const u8 {
var result = std.ArrayList(u8).init(self.allocator);
defer result.deinit();
var i: usize = 0;
while (i < text.len) {
// Check for pattern prefix
if (i + pattern.prefix.len <= text.len and
std.mem.eql(u8, text[i..][0..pattern.prefix.len], pattern.prefix))
{
// Found pattern, write prefix and redaction marker
try result.appendSlice(pattern.prefix);
try result.appendSlice(" [REDACTED]");
// Skip until end of line or suffix
i += pattern.prefix.len;
const end = if (pattern.suffix.len > 0)
std.mem.indexOf(u8, text[i..], pattern.suffix) orelse text.len
else
std.mem.indexOfScalar(u8, text[i..], '\n') orelse text.len;
i += end;
if (pattern.suffix.len > 0 and i < text.len) {
try result.appendSlice(pattern.suffix);
i += pattern.suffix.len;
}
} else {
try result.append(text[i]);
i += 1;
}
}
return result.toOwnedSlice();
}
/// Quick check if text might contain secrets
pub fn mightContainSecrets(self: Self, text: []const u8) bool {
for (self.patterns.items) |pattern| {
if (std.mem.indexOf(u8, text, pattern.prefix)) |_| {
return true;
}
}
return false;
}
};
/// Global secrets filter for application-wide use
var global_filter: ?SecretsFilter = null;
/// Initialize global secrets filter
pub fn init(allocator: std.mem.Allocator) void {
if (global_filter == null) {
global_filter = SecretsFilter.init(allocator);
}
}
/// Redact secrets from text using global filter
pub fn redact(text: []const u8) ![]const u8 {
if (global_filter) |*filter| {
return filter.redact(text);
}
return text; // No filter, return as-is
}
/// Check if text might contain secrets
pub fn mightContainSecrets(text: []const u8) bool {
if (global_filter) |filter| {
return filter.mightContainSecrets(text);
}
return false;
}
/// Safe print that redacts secrets
pub fn safePrint(writer: anytype, format: []const u8, args: anytype) !void {
var buf: [4096]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, format, args) catch {
try writer.print(format, args);
return;
};
const redacted = redact(msg) catch msg;
defer if (redacted.ptr != msg.ptr) {
if (global_filter) |*f| f.allocator.free(redacted);
};
try writer.writeAll(redacted);
}
/// Secure logging function
pub fn secureLog(comptime level: std.log.Level, comptime format: []const u8, args: anytype) void {
var buf: [4096]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, format, args) catch {
// Fallback to raw log if formatting fails
std.log.log(level, format, args);
return;
};
// Check if we need redaction
if (mightContainSecrets(msg)) {
const redacted = redact(msg) catch msg;
defer if (redacted.ptr != msg.ptr) {
if (global_filter) |*f| f.allocator.free(redacted);
};
std.log.log(level, "{s}", .{redacted});
} else {
std.log.log(level, "{s}", .{msg});
}
}
test "secrets redaction" {
const allocator = std.testing.allocator;
var filter = SecretsFilter.init(allocator);
defer filter.deinit();
// Test redaction
const input = "api_key=secret123 password=mypass";
const output = try filter.redact(input);
defer allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "secret123") == null);
try std.testing.expect(std.mem.indexOf(u8, output, "mypass") == null);
try std.testing.expect(std.mem.indexOf(u8, output, "[REDACTED]") != null);
}