diff --git a/src/protocol.zig b/src/protocol.zig index e2c5281..8e1c8ad 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -11,6 +11,12 @@ test { pub const max_request_bytes = 4096; +pub fn replayToolLogLine(allocator: std.mem.Allocator, session: *session_mod.Session, log_line: []const u8) ![]u8 { + const parsed = parseToolLog(log_line) orelse return allocator.dupe(u8, "err invalid tool log\n"); + if (!std.mem.eql(u8, parsed.status, "ok")) return allocator.dupe(u8, "err logged command failed\n"); + return commandResponse(allocator, session, parsed.command); +} + pub fn handleLine(allocator: std.mem.Allocator, session: *session_mod.Session, line: []const u8) ![]u8 { const trimmed = std.mem.trimEnd(u8, line, "\r\n"); if (std.mem.eql(u8, trimmed, "state")) return stateResponse(allocator, session); @@ -25,6 +31,9 @@ pub fn handleLine(allocator: std.mem.Allocator, session: *session_mod.Session, l if (std.mem.startsWith(u8, trimmed, "command ")) { return commandResponse(allocator, session, trimmed[8..]); } + if (std.mem.startsWith(u8, trimmed, "tool ")) { + return toolResponse(allocator, session, trimmed[5..]); + } return allocator.dupe(u8, "err unknown request\n"); } @@ -34,6 +43,51 @@ fn contextResponse(allocator: std.mem.Allocator, session: *session_mod.Session, return context_mod.sessionContextAlloc(allocator, session, payload, null, .{}); } +fn toolResponse(allocator: std.mem.Allocator, session: *session_mod.Session, payload: []const u8) ![]u8 { + const split = std.mem.indexOfScalar(u8, payload, ' ') orelse return allocator.dupe(u8, "err invalid tool request\n"); + const name = payload[0..split]; + const command = payload[split + 1 ..]; + if (!validToolName(name) or command.len == 0 or std.mem.startsWith(u8, command, "tool ")) return allocator.dupe(u8, "err invalid tool request\n"); + if (!validToolPayload(command)) return allocator.dupe(u8, "err invalid tool request\n"); + + const command_response = try commandResponse(allocator, session, command); + defer allocator.free(command_response); + const status: []const u8 = if (std.mem.startsWith(u8, command_response, "ok ")) "ok" else "err"; + return std.fmt.allocPrint( + allocator, + "tool-log\t{s}\t{s}\t{s}\n{s}", + .{ name, command, status, command_response }, + ); +} + +fn validToolName(name: []const u8) bool { + if (name.len == 0 or name.len > 32) return false; + for (name) |byte| { + if (!((byte >= 'a' and byte <= 'z') or (byte >= 'A' and byte <= 'Z') or (byte >= '0' and byte <= '9') or byte == '_' or byte == '-')) return false; + } + return true; +} + +fn validToolPayload(command: []const u8) bool { + for (command) |byte| { + if (byte == 0 or byte == '\t' or byte == '\n' or byte == '\r') return false; + } + return true; +} + +fn parseToolLog(log_line: []const u8) ?struct { name: []const u8, command: []const u8, status: []const u8 } { + const trimmed = std.mem.trimEnd(u8, log_line, "\r\n"); + if (!std.mem.startsWith(u8, trimmed, "tool-log\t")) return null; + var fields = std.mem.splitScalar(u8, trimmed, '\t'); + _ = fields.next() orelse return null; + const name = fields.next() orelse return null; + const command = fields.next() orelse return null; + const status = fields.next() orelse return null; + if (fields.next() != null) return null; + if (!validToolName(name) or !validToolPayload(command)) return null; + return .{ .name = name, .command = command, .status = status }; +} + fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, command: []const u8) ![]u8 { if (std.mem.eql(u8, command, "move_left")) return dispatchAndRespond(allocator, session, .move_left); if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right); @@ -171,6 +225,49 @@ fn stateResponse(allocator: std.mem.Allocator, session: *session_mod.Session) ![ ); } +test "regular: trusted tool command logs and replays deterministic state" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + try session.openFixture("ab"); + + const response = try handleLine(std.testing.allocator, &session, "tool pi insert c\n"); + defer std.testing.allocator.free(response); + try std.testing.expect(std.mem.startsWith(u8, response, "tool-log\tpi\tinsert c\tok\n")); + try std.testing.expect(std.mem.indexOf(u8, response, "bytes_len=3") != null); + + const log_end = std.mem.indexOfScalar(u8, response, '\n').?; + var replayed = session_mod.Session.init(std.testing.allocator); + defer replayed.deinit(); + try replayed.openFixture("ab"); + const replay_response = try replayToolLogLine(std.testing.allocator, &replayed, response[0..log_end]); + defer std.testing.allocator.free(replay_response); + try std.testing.expect(std.mem.indexOf(u8, replay_response, "bytes_len=3") != null); +} + +test "adversarial: tool path rejects hidden or malformed mutations" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + try session.openFixture("ab"); + + { + const response = try handleLine(std.testing.allocator, &session, "tool bad* insert c\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("err invalid tool request\n", response); + } + + { + const response = try handleLine(std.testing.allocator, &session, "tool pi tool nested\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("err invalid tool request\n", response); + } + + { + const response = try replayToolLogLine(std.testing.allocator, &session, "tool-log\tpi\tinsert c\terr"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("err logged command failed\n", response); + } +} + test "regular: protocol context request returns compact agent context" { var session = session_mod.Session.init(std.testing.allocator); defer session.deinit();