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
↓ nina-2bc90194.tar.gzatlas at this save
files changed2 files
src/main.zig+19 −0src/output.zig+97 −7src/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" {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