Skip to content
Closed
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 @@ -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 <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
47 changes: 47 additions & 0 deletions src/guardrails.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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" } },
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;
}

/// Check if guardrails should be enforced.
pub fn isAgentMode() bool {
return std.posix.getenv("ZAGI_AGENT") != null;
Expand Down Expand Up @@ -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);
Expand Down