2bc90194d359

output.confirm() now refuses on a closed/non-interactive stdin instead of assuming yes, printing a hint to pass --yes; adds a real --yes flag (main.zig parseArgs + output.setAutoYes) for legitimate automation; confirmDefaultNo is unchanged (NINA-005)

time
2026-07-02 14:09 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
fable audit fix — output.zig
parent
a801f719897c
2 files src/main.zig+19 −0src/output.zig+97 −7
src/main.zig+19 −0
@@ -24,6 +24,7 @@2424     json: bool = false,2525     last: u32 = 50,2626     user: bool = false,27+    yes: bool = false, // bypass output.confirm() for scripted/automated invocations2728 };2829 2930 pub fn main(startup: std.process.Init) !void {@@ -52,6 +53,11 @@5253     const argv = try startup.minimal.args.toSlice(arena);5354     const args = try parseArgs(arena, argv);5455 56+    // --yes bypasses output.confirm()'s prompts for legitimate automation (CI,57+    // hooks, cron). Without it, confirm() now refuses on a closed/non-interactive58+    // stdin instead of silently assuming yes (NINA-005).59+    if (args.yes) output.setAutoYes(true);60+5561     if (args.version) {5662         output.version();5763         return;@@ -316,6 +322,8 @@316322             result.user = true;317323         } else if (std.mem.eql(u8, a, "--json")) {318324             result.json = true;325+        } else if (std.mem.eql(u8, a, "--yes")) {326+            result.yes = true;319327         } else if (std.mem.eql(u8, a, "--last")) {320328             i += 1;321329             if (i < argv.len) result.last = std.fmt.parseInt(u32, argv[i], 10) catch 50;@@ -477,6 +485,17 @@477485     defer gpa.free(args.rest);478486     try std.testing.expectEqualStrings("service", args.command);479487     try std.testing.expectEqualStrings("list", args.sub);488+}489+490+test "parse args recognizes --yes" {491+    const gpa = std.testing.allocator;492+    const argv = [_][:0]const u8{ "nina", "clean", "--yes" };493+    const args = try parseArgs(gpa, &argv);494+    defer gpa.free(args.rest);495+    defer gpa.free(args.passthrough);496+    try std.testing.expectEqualStrings("clean", args.command);497+    try std.testing.expect(args.yes);498+    try std.testing.expectEqual(@as(usize, 0), args.passthrough.len);480499 }481500 482501 test "parse version flag" {

view file

src/output.zig+97 −7
@@ -55,8 +55,10 @@5555 5656 var _fw: Io.File.Writer = undefined;5757 var _w: *Io.Writer = undefined;58+var _io: Io = undefined;5859 var color_enabled: bool = true;5960 var teach_enabled: bool = false;61+var auto_yes: bool = false;6062 6163 // --- Build panel ---6264 @@ -244,6 +246,7 @@244246 245247 pub fn init(writer: *Io.Writer, io: std.Io, environ: *const std.process.Environ.Map, color_config: bool) void {246248     _w = writer;249+    _io = io;247250     color_enabled = color_config and248251         (std.Io.File.stdout().isTty(io) catch false) and249252         !environ.contains("NO_COLOR");@@ -253,6 +256,13 @@253256     teach_enabled = enabled;254257 }255258 259+// Set by --yes: bypass confirm() entirely for scripted/automated invocations260+// that want the old prompts skipped on purpose, instead of by accident via a261+// closed stdin. Does not affect confirmDefaultNo, which stays opt-in-only.262+pub fn setAutoYes(enabled: bool) void {263+    auto_yes = enabled;264+}265+256266 pub fn flush() void {257267     _w.flush() catch {};258268 }@@ -414,22 +424,102 @@414424 415425 // --- Confirmation prompt (returns true = yes) ---416426 417-pub fn confirm(prompt: []const u8) bool {427+fn stdinIsTty() bool {428+    return std.Io.File.stdin().isTty(_io) catch false;429+}430+431+fn printConfirmAbortHint() void {432+    p("   {s}refusing to assume yes on a closed/non-interactive stdin — pass --yes to confirm automatically{s}\n", .{ c(YELLOW), c(RESET) });433+}434+435+// Pure decision for an interactive read that has already happened: `saw_eof` is436+// true when the read loop hit end-of-input before a newline, `line` is whatever437+// was read (possibly empty). A real EOF with nothing typed (e.g. Ctrl-D at the438+// prompt) is treated the same as a closed/non-interactive stdin — silently439+// proceeding on a closed input stream is exactly the footgun this prompt exists440+// to prevent. Otherwise this is a plain [Y/n]-style default-yes parse.441+fn decideAnswer(saw_eof: bool, line: []const u8) bool {442+    if (saw_eof and line.len == 0) return false;443+    const trimmed = std.mem.trim(u8, line, " \t");444+    if (trimmed.len == 0) return true;445+    return trimmed[0] != 'n' and trimmed[0] != 'N';446+}447+448+// Split out from confirm() so a test can force the non-interactive branch449+// deterministically instead of depending on the test runner's own stdin.450+fn confirmWithTty(prompt: []const u8, is_tty: bool) bool {451+    if (auto_yes) return true;452+    if (!is_tty) {453+        printConfirmAbortHint();454+        return false;455+    }418456     p("   {s}continue? [Y/n]  ", .{prompt});419457     flush();420458     var buf: [64]u8 = undefined;421459     var i: usize = 0;460+    var eof = false;422461     while (i < buf.len) {423-        const n = std.posix.read(std.posix.STDIN_FILENO, buf[i .. i + 1]) catch break;424-        if (n == 0) break;462+        const n = std.posix.read(std.posix.STDIN_FILENO, buf[i .. i + 1]) catch {463+            eof = true;464+            break;465+        };466+        if (n == 0) {467+            eof = true;468+            break;469+        }425470         if (buf[i] == '\n') break;426471         i += 1;427472     }428-    const input = std.mem.trim(u8, buf[0..i], " \t");429-    if (input.len == 0) return true;430-    return input[0] != 'n' and input[0] != 'N';473+    const line = buf[0..i];474+    const answer = decideAnswer(eof, line);475+    if (!answer and eof and line.len == 0) printConfirmAbortHint();476+    return answer;431477 }432478 479+pub fn confirm(prompt: []const u8) bool {480+    return confirmWithTty(prompt, stdinIsTty());481+}482+483+fn initForTest() void {484+    const S = struct {485+        var buf: [256]u8 = undefined;486+        var discarding: std.Io.Writer.Discarding = .init(&buf);487+        var env: std.process.Environ.Map = .init(std.testing.allocator);488+    };489+    init(&S.discarding.writer, std.testing.io, &S.env, false);490+}491+492+test "confirmWithTty: non-tty stdin refuses and prints a hint instead of assuming yes (NINA-005)" {493+    initForTest();494+    defer setAutoYes(false);495+    try std.testing.expect(!confirmWithTty("test", false));496+}497+498+test "confirmWithTty: --yes bypasses the tty check entirely" {499+    initForTest();500+    setAutoYes(true);501+    defer setAutoYes(false);502+    try std.testing.expect(confirmWithTty("test", false));503+}504+505+test "decideAnswer: real EOF with nothing typed refuses (NINA-005)" {506+    try std.testing.expect(!decideAnswer(true, ""));507+}508+509+test "decideAnswer: bare enter accepts the [Y/n] default" {510+    try std.testing.expect(decideAnswer(false, ""));511+}512+513+test "decideAnswer: explicit y/Y accepts" {514+    try std.testing.expect(decideAnswer(false, "y"));515+    try std.testing.expect(decideAnswer(false, "Y"));516+}517+518+test "decideAnswer: explicit n/N refuses" {519+    try std.testing.expect(!decideAnswer(false, "n"));520+    try std.testing.expect(!decideAnswer(false, "N"));521+}522+433523 // Like confirm, but defaults to NO on an empty answer — for prompts that touch434524 // system files, where a stray Enter should never trigger the action.435525 pub fn confirmDefaultNo(question: []const u8) bool {@@ -986,7 +1076,7 @@9861076     for (cmds) |cmd| {9871077         p("   {s:<18}  {s}{s}{s}\n", .{ cmd[0], c(DIM), cmd[1], c(RESET) });9881078     }989-    p("\n   {s}flags: --on <machine>  --dry  --check  --no-apply  --last <n>  --all  --json  --version{s}\n", .{ c(DIM), c(RESET) });1079+    p("\n   {s}flags: --on <machine>  --dry  --check  --no-apply  --last <n>  --all  --json  --yes  --version{s}\n", .{ c(DIM), c(RESET) });9901080 }9911081 9921082 // Render a grouped command's subcommand list, shown when the group is invoked

view file