diff --git a/AGENTS.md b/AGENTS.md index 54f897f..ef0f473 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,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 :` | 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) | diff --git a/src/guardrails.zig b/src/guardrails.zig index 70e3732..70b18db 100644 --- a/src/guardrails.zig +++ b/src/guardrails.zig @@ -45,6 +45,11 @@ 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. @@ -77,6 +82,16 @@ pub const blocked_commands = [_]BlockedCommand{ .reason = "overwrites remote history (may cause data loss for collaborators)", }, + // 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 (refspec syntax)", + }, + // Stash destroyers .{ .pattern = .{ .subcommand = .{ .cmd = "stash", .sub = "drop" } }, @@ -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); + }, } } @@ -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; +} + /// Check if guardrails should be enforced. pub fn isAgentMode() bool { return std.posix.getenv("ZAGI_AGENT") != null; @@ -275,6 +302,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);