diff --git a/src/main.zig b/src/main.zig index 76c97d1..d82c223 100644 --- a/src/main.zig +++ b/src/main.zig @@ -8,6 +8,7 @@ const lsp = @import("lsp.zig"); const mobile_acceptance = @import("mobile_acceptance.zig"); const panel = @import("panel.zig"); const pi_bridge = @import("pi_bridge.zig"); +const profile = @import("profile.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const repo = @import("repo.zig"); @@ -26,6 +27,7 @@ const help_text = \\ mim [--help] \\ mim [--version] \\ mim context [task] [intent] + \\ mim profile \\ mim mimctl context [task] [intent] \\ mim --mimctl \\ @@ -71,6 +73,14 @@ pub fn main(init: std.process.Init) !u8 { try printLocalContext(allocator, init.io, &args, stdout); return 0; } + if (std.mem.eql(u8, arg, "profile")) { + try printProfile(allocator, init.io, stdout); + return 0; + } + if (isProfileStub(arg)) { + try printProfileStub(init.io, stderr, arg); + return 69; + } if (std.mem.eql(u8, arg, "mimctl")) { const sub = args.next() orelse { try stderr.writeStreamingAll(init.io, "mim: mimctl requires a subcommand\n"); @@ -134,6 +144,26 @@ fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.proces try stdout.writeStreamingAll(io, text); } +fn printProfile(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File) !void { + const text = try profile.statusAlloc(allocator); + defer allocator.free(text); + try stdout.writeStreamingAll(io, text); +} + +fn isProfileStub(arg: []const u8) bool { + return std.mem.eql(u8, arg, "remote") or + std.mem.eql(u8, arg, "plugin") or + std.mem.eql(u8, arg, "plugins") or + std.mem.eql(u8, arg, "background-agent") or + std.mem.eql(u8, arg, "bundled-pi"); +} + +fn printProfileStub(io: std.Io, stderr: std.Io.File, arg: []const u8) !void { + try stderr.writeStreamingAll(io, "mim: "); + try stderr.writeStreamingAll(io, arg); + try stderr.writeStreamingAll(io, " not built in this profile\n"); +} + fn requestMimctl( allocator: std.mem.Allocator, io: std.Io, @@ -193,6 +223,7 @@ test { _ = mobile_acceptance; _ = panel; _ = pi_bridge; + _ = profile; _ = protocol; _ = replay; _ = repo; @@ -207,6 +238,14 @@ test "regular: help text names the binary and smoke boundary" { try std.testing.expect(std.mem.indexOf(u8, help_text, "mim - mobile-first terminal code editor") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "canonical smoke path") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "mim context") != null); + try std.testing.expect(std.mem.indexOf(u8, help_text, "mim profile") != null); +} + +test "regular: excluded profile capabilities have explicit stubs" { + try std.testing.expect(isProfileStub("remote")); + try std.testing.expect(isProfileStub("plugin")); + try std.testing.expect(isProfileStub("background-agent")); + try std.testing.expect(!isProfileStub("context")); } test "regular: version output is stable enough for smoke checks" { diff --git a/src/profile.zig b/src/profile.zig new file mode 100644 index 0000000..3dc5840 --- /dev/null +++ b/src/profile.zig @@ -0,0 +1,51 @@ +const std = @import("std"); + +// Explicit v1 build/profile fences. +// req: governance/002, governance/003, testing/001, testing/002 + +test { + _ = statusAlloc; +} + +pub const active_profile = "dev-local"; +pub const local_socket = true; +pub const remote_transport_built = false; +pub const plugin_host_built = false; +pub const bundled_pi_built = false; +pub const background_agent_built = false; + +pub fn statusAlloc(allocator: std.mem.Allocator) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + try appendLine(allocator, &out, "profile:v1"); + try appendKV(allocator, &out, "active_profile", active_profile); + try appendKV(allocator, &out, "socket", if (local_socket) "local_only" else "disabled"); + try appendKV(allocator, &out, "remote_transport", if (remote_transport_built) "built" else "not_built"); + try appendKV(allocator, &out, "plugin_host", if (plugin_host_built) "built" else "not_built"); + try appendKV(allocator, &out, "bundled_pi", if (bundled_pi_built) "built" else "not_built"); + try appendKV(allocator, &out, "background_agent", if (background_agent_built) "built" else "not_built"); + try appendKV(allocator, &out, "tool_mutation", "protocol_logged"); + return out.toOwnedSlice(allocator); +} + +fn appendKV(allocator: std.mem.Allocator, out: *std.ArrayList(u8), key: []const u8, value: []const u8) !void { + try out.appendSlice(allocator, key); + try out.append(allocator, '='); + try out.appendSlice(allocator, value); + try out.append(allocator, '\n'); +} + +fn appendLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), line: []const u8) !void { + try out.appendSlice(allocator, line); + try out.append(allocator, '\n'); +} + +test "regular: profile status makes unavailable surfaces explicit" { + const text = try statusAlloc(std.testing.allocator); + defer std.testing.allocator.free(text); + try std.testing.expect(std.mem.indexOf(u8, text, "profile:v1\n") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "socket=local_only") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "remote_transport=not_built") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "plugin_host=not_built") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "tool_mutation=protocol_logged") != null); +} diff --git a/src/socket.zig b/src/socket.zig index 8459daa..41734a7 100644 --- a/src/socket.zig +++ b/src/socket.zig @@ -20,6 +20,7 @@ const SocketError = error{ ConnectFailed, WriteFailed, RequestTooLarge, + NonLocalPath, }; pub const Server = struct { @@ -29,11 +30,14 @@ pub const Server = struct { session: *session_mod.Session, pub fn listen(allocator: std.mem.Allocator, path: []const u8, session: *session_mod.Session) !Server { + try validateLocalSocketPath(path); unlinkPath(path); const fd = try createSocket(); errdefer closeFd(fd); const addr = try unixAddress(path); + const old_umask = restrictSocketUmask(); + defer restoreUmask(old_umask); if (std.posix.errno(std.posix.system.bind(fd, @ptrCast(&addr.un), addr.len)) != .SUCCESS) return SocketError.BindFailed; if (std.posix.errno(std.posix.system.listen(fd, 8)) != .SUCCESS) return SocketError.ListenFailed; @@ -72,6 +76,7 @@ pub const Server = struct { }; pub fn request(allocator: std.mem.Allocator, path: []const u8, line: []const u8) ![]u8 { + try validateLocalSocketPath(path); const fd = try createSocket(); defer closeFd(fd); @@ -104,6 +109,21 @@ const UnixAddress = struct { len: std.posix.socklen_t, }; +pub fn validateLocalSocketPath(path: []const u8) !void { + if (path.len == 0) return SocketError.NonLocalPath; + if (std.mem.indexOfScalar(u8, path, 0) != null) return SocketError.NonLocalPath; + if (std.mem.indexOfScalar(u8, path, '\n') != null or std.mem.indexOfScalar(u8, path, '\r') != null) return SocketError.NonLocalPath; + if (!std.mem.startsWith(u8, path, "/tmp/") and !std.mem.startsWith(u8, path, "/var/tmp/")) return SocketError.NonLocalPath; +} + +fn restrictSocketUmask() usize { + return std.os.linux.syscall1(.umask, 0o177); +} + +fn restoreUmask(old_umask: usize) void { + _ = std.os.linux.syscall1(.umask, old_umask); +} + fn unixAddress(path: []const u8) !UnixAddress { var addr: std.posix.sockaddr.un = .{ .family = std.posix.AF.UNIX, @@ -161,6 +181,14 @@ fn unlinkPath(path: []const u8) void { _ = std.posix.system.unlink(&posix_path); } +test "regular: local socket path validation rejects non-local transports" { + try validateLocalSocketPath("/tmp/mim.sock"); + try validateLocalSocketPath("/var/tmp/mim.sock"); + try std.testing.expectError(SocketError.NonLocalPath, validateLocalSocketPath("relative.sock")); + try std.testing.expectError(SocketError.NonLocalPath, validateLocalSocketPath("/home/user/mim.sock")); + try std.testing.expectError(SocketError.NonLocalPath, validateLocalSocketPath("/tmp/bad\n.sock")); +} + test "regular: local socket state request observes a running session" { var session = session_mod.Session.init(std.testing.allocator); defer session.deinit();