Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ These commands cause unrecoverable data loss and are blocked when `ZAGI_AGENT` i
| `restore .` | Discards all working tree changes |
| `restore --worktree` | Discards working tree changes |
| `push --force/-f` | Overwrites remote history |
| `push --delete` | Deletes remote branch |
| `push <remote> :<branch>` | Deletes remote branch (refspec syntax) |
| `stash drop` | Permanently deletes stashed changes |
| `stash clear` | Permanently deletes all stashes |
| `branch -D` | Force deletes branch (even if not merged) |
Expand Down
5 changes: 3 additions & 2 deletions src/cmds/tasks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,7 @@ fn runEdit(allocator: std.mem.Allocator, args: [][:0]u8, repo: ?*c.git_repositor
// Block edit in agent mode - agents should use append instead
const detect = @import("detect.zig");
if (detect.isAgentMode()) {
stdout.print("error: edit command blocked (ZAGI_AGENT is set)\n", .{}) catch {};
stdout.print("error: edit command blocked\n", .{}) catch {};
stdout.print("hint: use 'tasks append' to add notes to a task\n", .{}) catch {};
return Error.InvalidCommand;
}
Expand Down Expand Up @@ -1207,8 +1207,9 @@ fn runDelete(allocator: std.mem.Allocator, args: [][:0]u8, repo: ?*c.git_reposit
// Check if we should block this operation in agent mode
const detect = @import("detect.zig");
if (detect.isAgentMode()) {
stdout.print("error: delete command blocked (ZAGI_AGENT is set)\n", .{}) catch {};
stdout.print("error: delete command blocked\n", .{}) catch {};
stdout.print("reason: deleting tasks causes permanent data loss\n", .{}) catch {};
stdout.print("hint: ask the user to delete this task themselves, then confirm with you when done\n", .{}) catch {};
return Error.InvalidCommand;
}

Expand Down
59 changes: 53 additions & 6 deletions src/guardrails.zig
Original file line number Diff line number Diff line change
Expand Up @@ -45,36 +45,51 @@ pub const Pattern = union(enum) {
cmd: []const u8,
sub: []const u8,
},
/// Command + argument starting with prefix (e.g., "push" + ":" for refspec delete)
cmd_with_arg_prefix: struct {
cmd: []const u8,
prefix: []const u8,
},
};

/// Commands that cause unrecoverable data loss.
pub const blocked_commands = [_]BlockedCommand{
// Working tree destroyers
.{
.pattern = .{ .cmd_with_flag = .{ .cmd = "reset", .flag = "--hard" } },
.reason = "discards all uncommitted changes (unrecoverable)",
.reason = "discards all uncommitted changes",
},
.{
.pattern = .{ .cmd_with_arg = .{ .cmd = "checkout", .arg = "." } },
.reason = "discards all working tree changes (unrecoverable)",
.reason = "discards all working tree changes",
},
.{
.pattern = .{ .cmd_with_any_flag = .{ .cmd = "clean", .flags = &.{ "-f", "--force", "-fd", "-fx", "-fxd", "-d", "-x" } } },
.reason = "permanently deletes untracked files",
},
.{
.pattern = .{ .cmd_with_arg = .{ .cmd = "restore", .arg = "." } },
.reason = "discards all working tree changes (unrecoverable)",
.reason = "discards all working tree changes",
},
.{
.pattern = .{ .cmd_with_flag = .{ .cmd = "restore", .flag = "--worktree" } },
.reason = "discards working tree changes (unrecoverable)",
.reason = "discards working tree changes",
},

// Remote history destroyers
.{
.pattern = .{ .cmd_with_any_flag = .{ .cmd = "push", .flags = &.{ "-f", "--force", "--force-with-lease", "--force-if-includes" } } },
.reason = "overwrites remote history (may cause data loss for collaborators)",
.reason = "overwrites remote history",
},

// Remote branch deleters
.{
.pattern = .{ .cmd_with_any_flag = .{ .cmd = "push", .flags = &.{ "--delete", "-d" } } },
.reason = "deletes remote branch",
},
.{
.pattern = .{ .cmd_with_arg_prefix = .{ .cmd = "push", .prefix = ":" } },
.reason = "deletes remote branch via refspec syntax",
},

// Stash destroyers
Expand All @@ -90,7 +105,7 @@ pub const blocked_commands = [_]BlockedCommand{
// Branch force delete
.{
.pattern = .{ .cmd_with_flag = .{ .cmd = "branch", .flag = "-D" } },
.reason = "force deletes branch even if not merged (potential data loss)",
.reason = "force deletes branch even if not merged",
},
};

Expand Down Expand Up @@ -156,6 +171,10 @@ fn matchesPattern(cmd: []const u8, rest: []const [:0]const u8, pattern: Pattern)
if (rest.len == 0) return false;
return std.mem.eql(u8, std.mem.sliceTo(rest[0], 0), p.sub);
},
.cmd_with_arg_prefix => |p| {
if (!std.mem.eql(u8, cmd, p.cmd)) return false;
return hasArgWithPrefix(rest, p.prefix);
},
}
}

Expand Down Expand Up @@ -184,6 +203,14 @@ fn hasArg(args: []const [:0]const u8, target: []const u8) bool {
return false;
}

fn hasArgWithPrefix(args: []const [:0]const u8, prefix: []const u8) bool {
for (args) |arg_ptr| {
const arg = std.mem.sliceTo(arg_ptr, 0);
if (arg.len > prefix.len and std.mem.startsWith(u8, arg, prefix)) return true;
}
return false;
}

// Tests
const testing = std.testing;

Expand Down Expand Up @@ -270,6 +297,26 @@ test "allows push" {
try testing.expect(checkBlocked(&args) == null);
}

test "blocks push --delete" {
const args = toArgs(&.{ "git", "push", "origin", "--delete", "feature" });
try testing.expect(checkBlocked(&args) != null);
}

test "blocks push -d (delete)" {
const args = toArgs(&.{ "git", "push", "origin", "-d", "feature" });
try testing.expect(checkBlocked(&args) != null);
}

test "blocks push refspec delete syntax" {
const args = toArgs(&.{ "git", "push", "origin", ":feature" });
try testing.expect(checkBlocked(&args) != null);
}

test "allows push with normal refspec" {
const args = toArgs(&.{ "git", "push", "origin", "feature:feature" });
try testing.expect(checkBlocked(&args) == null);
}

test "blocks stash drop" {
const args = toArgs(&.{ "git", "stash", "drop" });
try testing.expect(checkBlocked(&args) != null);
Expand Down
18 changes: 2 additions & 16 deletions src/passthrough.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,9 @@ pub fn run(allocator: std.mem.Allocator, args: [][:0]u8) !void {
// Cast to const for checkBlocked
const const_args: []const [:0]const u8 = @ptrCast(args);
if (guardrails.checkBlocked(const_args)) |reason| {
// Build the command string for display
var cmd_display: [256]u8 = undefined;
var cmd_len: usize = 0;
for (args) |arg| {
const arg_slice = std.mem.sliceTo(arg, 0);
if (cmd_len > 0 and cmd_len < cmd_display.len) {
cmd_display[cmd_len] = ' ';
cmd_len += 1;
}
const to_copy = @min(arg_slice.len, cmd_display.len - cmd_len);
@memcpy(cmd_display[cmd_len..][0..to_copy], arg_slice[0..to_copy]);
cmd_len += to_copy;
}

stderr.print("error: destructive command blocked (ZAGI_AGENT is set)\n", .{}) catch {};
stderr.print("blocked: {s}\n", .{cmd_display[0..cmd_len]}) catch {};
stderr.print("error: destructive command blocked\n", .{}) catch {};
stderr.print("reason: {s}\n", .{reason}) catch {};
stderr.print("hint: ask the user to run this command themselves, then confirm with you when done\n", .{}) catch {};
std.process.exit(1);
}
}
Expand Down
2 changes: 1 addition & 1 deletion test/src/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ describe("zagi tasks delete", () => {
});

expect(result).toContain("error: delete command blocked");
expect(result).toContain("ZAGI_AGENT is set");
expect(result).toContain("permanent data loss");
expect(result).toContain("ask the user to delete this task");
});

test("succeeds when not in agent mode", () => {
Expand Down