From f9bc2440c982b4340aae61c381a1b49821df10b8 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Sat, 30 May 2026 01:27:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(mouse):=20#304=20PR=204h=20=E2=80=94?= =?UTF-8?q?=20ask=5Feach=20banner=20+=20session-trust=20+=20sudo=20guidanc?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final PR of #304. Adds the interactive trust posture to mouse_urls: click an untrusted URL → status-bar banner open ? [y]es / [a]llow / [t]rust / cancel asks the user what to do. | Key | Effect | |---|---| | `y` / `Y` | Open once; nothing persisted | | `a` / `A` | Add host to in-memory session-trust + open | | `t` / `T` | Session-trust + surface `sudo atty-guard urls allow ` guidance (NO open — opening would clobber the hint before the user can read it; click again to launch via the session-trust fast-path) | | Esc / Ctrl-C / Ctrl-U / `c` | Cancel; nothing happens | | anything else | swallowed (don't poison the shell prompt while banner is up) | The daemon-side `urls allow` RPC requires EUID 0 (see atty-guard/src/server.rs::handle_urls_allow), so atty can't write permanent trust itself; `[t]` surfaces the sudo command the user would run to persist the host across sessions. `hostTrusted` checks both `url_whitelist` (config-static) and the in-memory `session_hosts` ring before arming. Ring is FIFO-evicted at `session_trust_capacity` (default 64); dedup on add. While `armed=true`, statusText shows the banner and hint TTL is suspended. Subsequent clicks are silently consumed (no second banner). `whitelist_only` mode also gets the session-trust check for free — `[a]` works there too, just without the prompt. 1106/1106 tests pass (+8 new banner / session-trust / FIFO tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config.def.zig | 8 ++ src/modules/mouse_urls.zig | 207 +++++++++++++++++++++++++++-- src/modules/mouse_urls_tests.zig | 218 +++++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 13 deletions(-) diff --git a/src/config.def.zig b/src/config.def.zig index 6385b0d..ed14ec0 100644 --- a/src/config.def.zig +++ b/src/config.def.zig @@ -269,6 +269,14 @@ const atty = @import("atty"); // atty.modules.history.configure(.{}), // }; // +// // Interactive — `[y]es / [a]llow / [t]rust / cancel` banner on +// // click of an untrusted host. `[a]` adds to in-memory +// // session-trust (subsequent clicks fast-path); `[t]` surfaces the +// // `sudo atty-guard urls allow ` guidance for permanent +// // persistence (atty can't write the daemon's trust file itself +// // — `urls allow` is an EUID-0 RPC). +// // atty.modules.mouse_urls.configure(.{ .mode = .ask_each }), +// // // Paranoid posture — click is always a no-op + hint: // // atty.modules.mouse_urls.configure(.{ .mode = .never }), diff --git a/src/modules/mouse_urls.zig b/src/modules/mouse_urls.zig index c9142a7..50fe272 100644 --- a/src/modules/mouse_urls.zig +++ b/src/modules/mouse_urls.zig @@ -28,11 +28,17 @@ pub const Mode = enum { /// Click is a no-op; the URL is not opened. Paranoid default /// suitable for shared / multi-tenant hosts. never, - /// Open URLs whose host matches an entry in `url_whitelist`; - /// silent no-op for everything else (status hint surfaces the - /// reason). Default — least-surprise behaviour while preserving - /// user agency over what gets launched. + /// Open URLs whose host matches an entry in `url_whitelist` OR + /// the in-memory session-trust set. Silent no-op + status hint + /// for anything else. The least-surprise mode for users who want + /// agency over what gets launched. whitelist_only, + /// On click of an untrusted URL, arm a banner with `[y]/[a]/[t]/ + /// cancel`: open once / session-trust / print the sudo guidance + /// for permanent trust / cancel. Trusted hosts (`url_whitelist` + /// or in-memory session-trust) still fast-path through without a + /// banner. + ask_each, }; pub const Config = struct { @@ -56,8 +62,24 @@ pub const Config = struct { row_bytes: usize = 1024, /// Status-hint TTL after a click-on-non-whitelisted-URL. Lets - /// the user notice the policy decision without polling. + /// the user notice the policy decision without polling. While + /// the `ask_each` banner is armed the TTL is suspended (the + /// banner persists until a keystroke). hint_ttl_ms: u64 = 4000, + + /// Maximum hosts kept in the in-memory session-trust set. Old + /// entries are evicted oldest-first. 64 is plenty for a typical + /// session; raise if you click "a" on many distinct hosts. + session_trust_capacity: usize = 64, +}; + +pub const HostSlot = struct { + bytes: [256]u8 = undefined, + len: u8 = 0, + + pub fn slice(self: *const HostSlot) []const u8 { + return self.bytes[0..self.len]; + } }; pub fn configure(comptime cfg: Config) type { @@ -65,6 +87,7 @@ pub fn configure(comptime cfg: Config) type { if (cfg.ring_rows == 0) @compileError("mouse_urls: ring_rows must be > 0"); if (cfg.row_bytes == 0) @compileError("mouse_urls: row_bytes must be > 0"); if (cfg.opener.len == 0) @compileError("mouse_urls: opener must not be empty"); + if (cfg.session_trust_capacity == 0) @compileError("mouse_urls: session_trust_capacity must be > 0"); } return struct { pub const name = "mouse_urls"; @@ -85,6 +108,23 @@ pub fn configure(comptime cfg: Config) type { hint_len: usize = 0, hint_set_at_ms: u64 = 0, + // ask_each banner state. While `armed` is true, the + // statusText shows `Open ? [y]/[a]/[t]/cancel` and + // hint_ttl is suspended. Cleared on any keystroke + // response (y/a/t/Esc/Ctrl-C/c). + armed: bool = false, + armed_url_buf: [2048]u8 = undefined, + armed_url_len: usize = 0, + armed_host_buf: [256]u8 = undefined, + armed_host_len: usize = 0, + + // Session-trust ring of hosts (no port). [a]llow appends + // here; subsequent clicks on the same host fast-path + // through the whitelist check. FIFO eviction at capacity. + session_hosts: []HostSlot = &.{}, + session_head: usize = 0, // next-write index + session_filled: usize = 0, // entries seen (≤ capacity) + // Optional clock for tests to inject monotonic time. nil // = real CLOCK_MONOTONIC. test_clock_ms: ?u64 = null, @@ -97,13 +137,17 @@ pub fn configure(comptime cfg: Config) type { const line_starts = try allocator.alloc(usize, cfg.ring_rows); errdefer allocator.free(line_starts); const line_lens = try allocator.alloc(u16, cfg.ring_rows); + errdefer allocator.free(line_lens); + const session_hosts = try allocator.alloc(HostSlot, cfg.session_trust_capacity); for (line_starts, 0..) |*s, i| s.* = i * cfg.row_bytes; for (line_lens) |*l| l.* = 0; + for (session_hosts) |*h| h.* = .{}; return .{ .allocator = allocator, .ring = ring, .line_starts = line_starts, .line_lens = line_lens, + .session_hosts = session_hosts, }; } @@ -112,6 +156,7 @@ pub fn configure(comptime cfg: Config) type { rt.allocator.free(rt.ring); rt.allocator.free(rt.line_starts); rt.allocator.free(rt.line_lens); + rt.allocator.free(rt.session_hosts); } pub fn onOutput(rt: *Runtime, ctx: *m.Context, output: []const u8) !void { @@ -125,6 +170,7 @@ pub fn configure(comptime cfg: Config) type { evt: mouse.Event, ) m.Error!dispatch.MouseAction { if (evt.button != .left or evt.kind != .press) return .passthrough; + if (rt.armed) return .consume; // ignore further clicks while a banner is open const line = clickedLine(cfg, rt, ctx, evt.row) orelse return .passthrough; const hit = detect.find(line, evt.col, .{}) orelse return .passthrough; @@ -135,18 +181,147 @@ pub fn configure(comptime cfg: Config) type { return .consume; }, .whitelist_only => { - if (hostMatches(hit.host, cfg.url_whitelist)) { - spawnOpener(cfg.opener, hit.url) catch |err| { - setHintErr(rt, hit.url, err); - return .consume; - }; - setHint(rt, "opening: ", hit.url); - } else { - setHint(rt, "host not in whitelist: ", hit.host); + if (hostTrusted(rt, hit.host)) { + return launch(rt, hit.url); } + setHint(rt, "host not in whitelist: ", hit.host); return .consume; }, + .ask_each => { + if (hostTrusted(rt, hit.host)) { + return launch(rt, hit.url); + } + arm(rt, hit.url, hit.host); + return .consume; + }, + } + } + + pub fn onInput(rt: *Runtime, ctx: *m.Context, input: []const u8) m.Error!m.Action { + _ = ctx; + if (!rt.armed) return .forward; + if (input.len == 0) return .forward; + + const c = input[0]; + // Esc / Ctrl-C / Ctrl-U / 'c' cancel. + if (c == 0x1b or c == 0x03 or c == 0x15 or c == 'c' or c == 'C') { + disarm(rt); + setHint(rt, "url-open cancelled: ", rt.armed_url_buf[0..0]); + return .swallow; + } + + switch (c) { + 'y', 'Y' => { + const url = rt.armed_url_buf[0..rt.armed_url_len]; + const act = launch(rt, url) catch dispatch.MouseAction.passthrough; + _ = act; + disarm(rt); + return .swallow; + }, + 'a', 'A' => { + sessionTrustAdd(rt, rt.armed_host_buf[0..rt.armed_host_len]); + const url = rt.armed_url_buf[0..rt.armed_url_len]; + _ = launch(rt, url) catch dispatch.MouseAction.passthrough; + disarm(rt); + return .swallow; + }, + 't', 'T' => { + // [t] adds to session-trust + surfaces the + // permanent-trust guidance, but does NOT open + // — opening would clobber the guidance hint + // before the user has a chance to read it. + // Clicking the same URL again then takes the + // session-trust fast-path and opens immediately. + const host = rt.armed_host_buf[0..rt.armed_host_len]; + setPersistHint(rt, host); + disarm(rt); + return .swallow; + }, + else => return .swallow, // any other key while armed: ignore (don't pass to shell) + } + } + + pub fn statusText(rt: *Runtime, ctx: *m.Context) m.Error!?[]const u8 { + _ = ctx; + if (!rt.armed) return null; + // Use hint_buf as scratch — when armed there's no + // competing TTL hint to clobber (armed disables it). + var w: usize = 0; + const cap = rt.hint_buf.len; + const prefix: []const u8 = "open "; + w += copyClamped(rt.hint_buf[w..cap], prefix); + w += copyClamped(rt.hint_buf[w..cap], rt.armed_host_buf[0..rt.armed_host_len]); + const suffix: []const u8 = "? [y]es / [a]llow / [t]rust / cancel"; + w += copyClamped(rt.hint_buf[w..cap], suffix); + return rt.hint_buf[0..w]; + } + + fn arm(rt: *Runtime, url: []const u8, host: []const u8) void { + rt.armed = true; + const ucopy = @min(url.len, rt.armed_url_buf.len); + @memcpy(rt.armed_url_buf[0..ucopy], url[0..ucopy]); + rt.armed_url_len = ucopy; + const hcopy = @min(host.len, rt.armed_host_buf.len); + @memcpy(rt.armed_host_buf[0..hcopy], host[0..hcopy]); + rt.armed_host_len = hcopy; + // Clear any pending TTL hint — banner is the active UI. + rt.hint_len = 0; + } + + fn disarm(rt: *Runtime) void { + rt.armed = false; + rt.armed_url_len = 0; + rt.armed_host_len = 0; + } + + fn launch(rt: *Runtime, url: []const u8) m.Error!dispatch.MouseAction { + spawnOpener(cfg.opener, url) catch |err| { + setHintErr(rt, url, err); + return .consume; + }; + setHint(rt, "opening: ", url); + return .consume; + } + + fn hostTrusted(rt: *Runtime, host: []const u8) bool { + if (hostMatches(host, cfg.url_whitelist)) return true; + const host_no_port = stripPort(host); + const n = @min(rt.session_filled, rt.session_hosts.len); + for (rt.session_hosts[0..n]) |*slot| { + if (std.ascii.eqlIgnoreCase(slot.slice(), host_no_port)) return true; + } + return false; + } + + fn sessionTrustAdd(rt: *Runtime, host: []const u8) void { + const host_no_port = stripPort(host); + if (host_no_port.len == 0) return; + // Dedupe. + const n = @min(rt.session_filled, rt.session_hosts.len); + for (rt.session_hosts[0..n]) |*slot| { + if (std.ascii.eqlIgnoreCase(slot.slice(), host_no_port)) return; } + const idx = rt.session_head; + const slot = &rt.session_hosts[idx]; + const copy = @min(host_no_port.len, slot.bytes.len); + @memcpy(slot.bytes[0..copy], host_no_port[0..copy]); + slot.len = @intCast(copy); + rt.session_head = (rt.session_head + 1) % rt.session_hosts.len; + if (rt.session_filled < rt.session_hosts.len) rt.session_filled += 1; + } + + fn setPersistHint(rt: *Runtime, host: []const u8) void { + // Daemon-side `urls allow` requires EUID 0 (see + // atty-guard/src/server.rs::handle_urls_allow), so atty + // can't write it directly. Surface the sudo command; + // session-trust adds the host in-memory for THIS session + // so the user gets the immediate effect without leaving + // the prompt. + sessionTrustAdd(rt, host); + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "session-trusted; persist: sudo atty-guard urls allow {s}", .{stripPort(host)}) catch + return setHint(rt, "session-trusted: ", host); + setHint(rt, "", msg); } pub fn provideHintText(rt: *Runtime, ctx: *m.Context) m.Error!?[]const u8 { @@ -303,6 +478,12 @@ fn stripPort(host: []const u8) []const u8 { return host[0..idx]; } +fn copyClamped(dst: []u8, src: []const u8) usize { + const n = @min(dst.len, src.len); + @memcpy(dst[0..n], src[0..n]); + return n; +} + fn setHint(rt: anytype, prefix: []const u8, body: []const u8) void { var w: usize = 0; const cap = rt.hint_buf.len; diff --git a/src/modules/mouse_urls_tests.zig b/src/modules/mouse_urls_tests.zig index bbf5291..00de29f 100644 --- a/src/modules/mouse_urls_tests.zig +++ b/src/modules/mouse_urls_tests.zig @@ -229,6 +229,224 @@ test "wildcard whitelist entry covers subdomain on click" { try testing.expect(std.mem.indexOf(u8, hint, "api.github.com") != null); } +test "ask_each — untrusted click arms banner with statusText prompt" { + const Mod = configure(.{ + .mode = .ask_each, + .url_whitelist = &.{}, + .hint_ttl_ms = 60_000, + }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + + try Mod.onOutput(&rt, &c, "see https://blog.example.com/post\n"); + const click: mouse.Event = .{ .button = .left, .kind = .press, .col = 7, .row = 1, .mods = .{} }; + try testing.expectEqual(dispatch.MouseAction.consume, try Mod.onMouseClick(&rt, &c, click)); + + try testing.expect(rt.armed); + const banner = (try Mod.statusText(&rt, &c)).?; + try testing.expectEqualStrings( + "open blog.example.com? [y]es / [a]llow / [t]rust / cancel", + banner, + ); +} + +test "ask_each — 'y' opens once, doesn't add to session-trust" { + const Mod = configure(.{ + .mode = .ask_each, + .opener = "true", + .hint_ttl_ms = 60_000, + }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + + try Mod.onOutput(&rt, &c, "https://once.example/path\n"); + const click: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = 1, .mods = .{} }; + _ = try Mod.onMouseClick(&rt, &c, click); + try testing.expect(rt.armed); + + const action = try Mod.onInput(&rt, &c, "y"); + try testing.expectEqual(m.Action.swallow, action); + try testing.expect(!rt.armed); + try testing.expectEqual(@as(usize, 0), rt.session_filled); + const hint = (try Mod.provideHintText(&rt, &c)).?; + try testing.expectEqualStrings("opening: https://once.example/path", hint); +} + +test "ask_each — 'a' adds host to session-trust + opens" { + const Mod = configure(.{ + .mode = .ask_each, + .opener = "true", + .hint_ttl_ms = 60_000, + }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + + try Mod.onOutput(&rt, &c, "https://allow.example/x\n"); + const click: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = 1, .mods = .{} }; + _ = try Mod.onMouseClick(&rt, &c, click); + + _ = try Mod.onInput(&rt, &c, "a"); + try testing.expect(!rt.armed); + try testing.expectEqual(@as(usize, 1), rt.session_filled); + try testing.expectEqualStrings("allow.example", rt.session_hosts[0].slice()); + + // Subsequent click on the same host fast-paths through the + // banner — directly opens. + try Mod.onOutput(&rt, &c, "https://allow.example/y\n"); + const click2: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = 2, .mods = .{} }; + _ = try Mod.onMouseClick(&rt, &c, click2); + try testing.expect(!rt.armed); +} + +test "ask_each — 't' surfaces sudo guidance + session-trusts" { + const Mod = configure(.{ + .mode = .ask_each, + .opener = "true", + .hint_ttl_ms = 60_000, + }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + + try Mod.onOutput(&rt, &c, "https://trust.example/x\n"); + const click: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = 1, .mods = .{} }; + _ = try Mod.onMouseClick(&rt, &c, click); + + _ = try Mod.onInput(&rt, &c, "t"); + try testing.expect(!rt.armed); + try testing.expectEqual(@as(usize, 1), rt.session_filled); + const hint = (try Mod.provideHintText(&rt, &c)).?; + try testing.expect(std.mem.indexOf(u8, hint, "sudo atty-guard urls allow trust.example") != null); +} + +test "ask_each — Esc / Ctrl-C / 'c' cancel the banner without opening" { + for ([_]u8{ 0x1b, 0x03, 'c', 'C' }) |key| { + const Mod = configure(.{ + .mode = .ask_each, + .opener = "true", + .hint_ttl_ms = 60_000, + }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + + try Mod.onOutput(&rt, &c, "https://cancel.example/x\n"); + const click: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = 1, .mods = .{} }; + _ = try Mod.onMouseClick(&rt, &c, click); + try testing.expect(rt.armed); + + const buf = [_]u8{key}; + const action = try Mod.onInput(&rt, &c, &buf); + try testing.expectEqual(m.Action.swallow, action); + try testing.expect(!rt.armed); + try testing.expectEqual(@as(usize, 0), rt.session_filled); + } +} + +test "ask_each — unrelated keystroke while armed is swallowed (not forwarded)" { + const Mod = configure(.{ .mode = .ask_each, .opener = "true", .hint_ttl_ms = 60_000 }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + try Mod.onOutput(&rt, &c, "https://example.com\n"); + _ = try Mod.onMouseClick(&rt, &c, .{ .button = .left, .kind = .press, .col = 5, .row = 1, .mods = .{} }); + + const action = try Mod.onInput(&rt, &c, "x"); + try testing.expectEqual(m.Action.swallow, action); + try testing.expect(rt.armed); // still armed after unrelated key +} + +test "ask_each — pre-existing whitelist entry fast-paths past the banner" { + const Mod = configure(.{ + .mode = .ask_each, + .url_whitelist = &.{"trusted.example"}, + .opener = "true", + .hint_ttl_ms = 60_000, + }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + + try Mod.onOutput(&rt, &c, "https://trusted.example/x\n"); + _ = try Mod.onMouseClick(&rt, &c, .{ .button = .left, .kind = .press, .col = 5, .row = 1, .mods = .{} }); + try testing.expect(!rt.armed); + const hint = (try Mod.provideHintText(&rt, &c)).?; + try testing.expectEqualStrings("opening: https://trusted.example/x", hint); +} + +test "session_trust FIFO eviction at capacity" { + const Mod = configure(.{ + .mode = .ask_each, + .opener = "true", + .session_trust_capacity = 3, + .hint_ttl_ms = 60_000, + }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + + inline for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |suffix, i| { + const out = "https://h" ++ suffix ++ ".example\n"; + try Mod.onOutput(&rt, &c, out); + const click: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = @intCast(i + 1), .mods = .{} }; + _ = try Mod.onMouseClick(&rt, &c, click); + _ = try Mod.onInput(&rt, &c, "a"); + } + try testing.expectEqual(@as(usize, 3), rt.session_filled); + // ha was evicted; hb hc hd remain (after wrap, ring contains + // hd hb hc in head-wrap order — set membership is what matters). + const has_a = blk: { + for (rt.session_hosts) |*slot| { + if (std.mem.eql(u8, slot.slice(), "ha.example")) break :blk true; + } + break :blk false; + }; + try testing.expect(!has_a); +} + test "hint TTL expiry suppresses stale hint" { const Mod = configure(.{ .mode = .never, From edcd1a71b893a6aff4ed1ec0a7bd0a30777d3207 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Sat, 30 May 2026 01:31:10 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(mouse=5Furls):=20subagent=20round=201?= =?UTF-8?q?=20=E2=80=94=20tighter=20FIFO=20+=20paired=20y/a=20test=20+=20n?= =?UTF-8?q?its?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FIFO test: expand to 5 adds with cap=3, assert hc/hd/he present AND ha/hb evicted, plus a fast-path proof that surviving entries hostTrust. Was: only checked ha missing — wouldn't have caught a "all entries evicted" regression. - New paired test "'y' on first host, 'a' on second": locks the invariant that 'y' doesn't fall through to 'a'. Previously each branch had its own fresh runtime so cross-arm contamination wouldn't have been caught. - `setPersistHint` long-host fallback now uses `stripPort(host)` instead of raw host — consistent with the normal path. - Module docstring + config.def.zig now spell out the ordering rule: place mouse_urls BEFORE guardrail in the modules tuple so the banner's y/a/t keystrokes win over guardrail's armed prompt. 1107/1107 pass (+1 paired y/a test; FIFO test rewrite is net-zero). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config.def.zig | 4 +- src/modules/mouse_urls.zig | 14 ++++++- src/modules/mouse_urls_tests.zig | 69 ++++++++++++++++++++++++++------ 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/config.def.zig b/src/config.def.zig index ed14ec0..db6c38f 100644 --- a/src/config.def.zig +++ b/src/config.def.zig @@ -274,7 +274,9 @@ const atty = @import("atty"); // // session-trust (subsequent clicks fast-path); `[t]` surfaces the // // `sudo atty-guard urls allow ` guidance for permanent // // persistence (atty can't write the daemon's trust file itself -// // — `urls allow` is an EUID-0 RPC). +// // — `urls allow` is an EUID-0 RPC). Place `mouse_urls` BEFORE +// // `guardrail` in `modules` so the banner's `y`/`a`/`t` keystrokes +// // beat guardrail's armed-prompt consumption. // // atty.modules.mouse_urls.configure(.{ .mode = .ask_each }), // // // Paranoid posture — click is always a no-op + hint: diff --git a/src/modules/mouse_urls.zig b/src/modules/mouse_urls.zig index 50fe272..bcf6db5 100644 --- a/src/modules/mouse_urls.zig +++ b/src/modules/mouse_urls.zig @@ -13,6 +13,15 @@ //! Capture model mirrors mouse_links — a per-module ring of recent //! output rows with SGR / OSC strip. Sharing the ring across both //! modules is a future refactor (the per-byte work is light). +//! +//! Module ordering: when `.ask_each` mode is on, the armed banner's +//! key consumption (returning `.swallow` from `onInput`) must beat +//! other input-consuming modules in dispatch order. Place +//! `mouse_urls` BEFORE `guardrail` (which also swallows on its own +//! armed banner) in the user's `modules` tuple — otherwise an open +//! guardrail prompt could eat the `y/a/t` keystroke meant for the +//! URL banner. The same rule applies to any future input-consuming +//! module. const std = @import("std"); const m = @import("../module.zig"); @@ -318,9 +327,10 @@ pub fn configure(comptime cfg: Config) type { // so the user gets the immediate effect without leaving // the prompt. sessionTrustAdd(rt, host); + const host_clean = stripPort(host); var buf: [256]u8 = undefined; - const msg = std.fmt.bufPrint(&buf, "session-trusted; persist: sudo atty-guard urls allow {s}", .{stripPort(host)}) catch - return setHint(rt, "session-trusted: ", host); + const msg = std.fmt.bufPrint(&buf, "session-trusted; persist: sudo atty-guard urls allow {s}", .{host_clean}) catch + return setHint(rt, "session-trusted: ", host_clean); setHint(rt, "", msg); } diff --git a/src/modules/mouse_urls_tests.zig b/src/modules/mouse_urls_tests.zig index 00de29f..f9102de 100644 --- a/src/modules/mouse_urls_tests.zig +++ b/src/modules/mouse_urls_tests.zig @@ -412,7 +412,7 @@ test "ask_each — pre-existing whitelist entry fast-paths past the banner" { try testing.expectEqualStrings("opening: https://trusted.example/x", hint); } -test "session_trust FIFO eviction at capacity" { +test "session_trust FIFO eviction at capacity — strict ordering" { const Mod = configure(.{ .mode = .ask_each, .opener = "true", @@ -428,23 +428,68 @@ test "session_trust FIFO eviction at capacity" { defer scratch.deinit(testing.allocator); var c = ctx(&line, &scratch); - inline for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |suffix, i| { + const hosts = [_][]const u8{ "ha.example", "hb.example", "hc.example", "hd.example", "he.example" }; + inline for ([_][]const u8{ "a", "b", "c", "d", "e" }, 1..) |suffix, row| { const out = "https://h" ++ suffix ++ ".example\n"; try Mod.onOutput(&rt, &c, out); - const click: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = @intCast(i + 1), .mods = .{} }; + const click: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = @intCast(row), .mods = .{} }; _ = try Mod.onMouseClick(&rt, &c, click); _ = try Mod.onInput(&rt, &c, "a"); } + try testing.expectEqual(@as(usize, 3), rt.session_filled); - // ha was evicted; hb hc hd remain (after wrap, ring contains - // hd hb hc in head-wrap order — set membership is what matters). - const has_a = blk: { - for (rt.session_hosts) |*slot| { - if (std.mem.eql(u8, slot.slice(), "ha.example")) break :blk true; - } - break :blk false; - }; - try testing.expect(!has_a); + + // After 5 adds with capacity 3, ha + hb were evicted; hc, hd, he survive. + try testing.expect(!ringHas(&rt, hosts[0])); // ha evicted + try testing.expect(!ringHas(&rt, hosts[1])); // hb evicted + try testing.expect(ringHas(&rt, hosts[2])); // hc + try testing.expect(ringHas(&rt, hosts[3])); // hd + try testing.expect(ringHas(&rt, hosts[4])); // he + + // hc must still hostTrust — proves the surviving entries work. + try Mod.onOutput(&rt, &c, "https://hc.example/p\n"); + const click_hc: mouse.Event = .{ .button = .left, .kind = .press, .col = 5, .row = 6, .mods = .{} }; + _ = try Mod.onMouseClick(&rt, &c, click_hc); + try testing.expect(!rt.armed); // fast-path through session-trust +} + +fn ringHas(rt: anytype, host: []const u8) bool { + for (rt.session_hosts) |*slot| { + if (std.mem.eql(u8, slot.slice(), host)) return true; + } + return false; +} + +test "ask_each — 'y' on first host, 'a' on second: only the 'a' host is trusted" { + // Inverse-coverage for the 'y' test: prove 'y' does NOT fall through + // to the 'a' arm by interleaving them in the same Runtime. + const Mod = configure(.{ + .mode = .ask_each, + .opener = "true", + .hint_ttl_ms = 60_000, + }); + var rt = try Mod.attach(testing.allocator, test_io); + defer Mod.detach(&rt, test_io); + rt.test_clock_ms = 1000; + + var line = LineState{}; + var scratch: std.ArrayList(u8) = .empty; + defer scratch.deinit(testing.allocator); + var c = ctx(&line, &scratch); + + try Mod.onOutput(&rt, &c, "https://once.example/p\n"); + _ = try Mod.onMouseClick(&rt, &c, .{ .button = .left, .kind = .press, .col = 5, .row = 1, .mods = .{} }); + _ = try Mod.onInput(&rt, &c, "y"); + try testing.expectEqual(@as(usize, 0), rt.session_filled); + + try Mod.onOutput(&rt, &c, "https://twice.example/p\n"); + _ = try Mod.onMouseClick(&rt, &c, .{ .button = .left, .kind = .press, .col = 5, .row = 2, .mods = .{} }); + _ = try Mod.onInput(&rt, &c, "a"); + + try testing.expectEqual(@as(usize, 1), rt.session_filled); + try testing.expectEqualStrings("twice.example", rt.session_hosts[0].slice()); + // The 'y' host must NOT have been trusted. + try testing.expect(!ringHas(&rt, "once.example")); } test "hint TTL expiry suppresses stale hint" {