Skip to content

Commit 5259094

Browse files
Ambient Code Botclaude
andcommitted
feat(runner): add git safety guardrails to prevent destructive operations
Add comprehensive git guardrails to the runner to prevent AI agents from performing irreversible git operations without user authorization. This addresses incidents where agents deleted remote branches (permanently closing PRs), force-pushed to user forks, and ran destructive local operations without backups. Changes: - New git_guardrails.py module with command validation logic that detects dangerous operations (ref deletion, force push, API ref manipulation, reset --hard, clean -fd, etc.) and token exposure in commands - System prompt now includes explicit Git Safety Guardrails section with hard rules and an escalation protocol when git operations fail - 46 unit tests for the guardrails module + 2 prompt integration tests Closes #1111 Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 70171b4 commit 5259094

4 files changed

Lines changed: 737 additions & 0 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
"""
2+
Git guardrails for detecting and classifying destructive git operations.
3+
4+
Provides command validation to identify dangerous git and GitHub CLI/API
5+
operations that could cause irreversible damage (branch deletion, force
6+
pushes, history rewriting, etc.).
7+
8+
These checks are used by the system prompt builder to inject safety
9+
instructions and can be used by future hook-based enforcement layers.
10+
"""
11+
12+
import logging
13+
import re
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class GitGuardrailViolation:
19+
"""Describes a guardrail violation found in a command."""
20+
21+
def __init__(self, rule: str, severity: str, command: str, explanation: str) -> None:
22+
self.rule = rule
23+
self.severity = severity # "block" or "warn"
24+
self.command = command
25+
self.explanation = explanation
26+
27+
def __repr__(self) -> str:
28+
return f"GitGuardrailViolation(rule={self.rule!r}, severity={self.severity!r})"
29+
30+
31+
# ---------------------------------------------------------------------------
32+
# Destructive command patterns
33+
# ---------------------------------------------------------------------------
34+
35+
# Patterns that should be blocked outright (irreversible / high-blast-radius)
36+
_BLOCKED_PATTERNS: list[tuple[str, str, re.Pattern[str]]] = [
37+
(
38+
"delete_remote_ref",
39+
"Deleting a remote branch/ref can permanently close associated PRs",
40+
re.compile(
41+
r"""
42+
(?:gh\s+api|curl) # GitHub API call via gh or curl
43+
.* # any intervening flags/args
44+
-X\s*DELETE # HTTP DELETE method
45+
.* # any intervening text
46+
/git/refs/ # targeting a git ref
47+
""",
48+
re.VERBOSE | re.IGNORECASE,
49+
),
50+
),
51+
(
52+
"api_force_update_ref",
53+
"Force-updating a remote ref via the GitHub API bypasses git safety mechanisms",
54+
re.compile(
55+
r"""
56+
(?:gh\s+api|curl) # GitHub API call
57+
.* # any intervening flags/args
58+
(?:PATCH|PUT) # HTTP update method
59+
.* # any intervening text
60+
/git/refs/ # targeting a git ref
61+
.* # any intervening text
62+
["\']?force["\']?\s* # force parameter
63+
:\s*true # set to true
64+
""",
65+
re.VERBOSE | re.IGNORECASE,
66+
),
67+
),
68+
(
69+
"api_create_commit_on_ref",
70+
"Creating commits directly via the GitHub API bypasses local git safeguards",
71+
re.compile(
72+
r"""
73+
(?:gh\s+api|curl) # GitHub API call
74+
.* # any intervening flags/args
75+
(?:POST|PATCH|PUT) # HTTP write method
76+
.* # any intervening text
77+
/git/(?:commits|trees|blobs) # low-level git data API
78+
""",
79+
re.VERBOSE | re.IGNORECASE,
80+
),
81+
),
82+
(
83+
"force_push",
84+
"Force pushing overwrites remote history and can destroy others' work",
85+
re.compile(
86+
r"""
87+
git\s+push\s+ # git push command
88+
.* # any flags/args
89+
--force(?!\-with\-lease) # --force but NOT --force-with-lease
90+
""",
91+
re.VERBOSE,
92+
),
93+
),
94+
(
95+
"force_push_short",
96+
"Force pushing (-f) overwrites remote history and can destroy others' work",
97+
re.compile(
98+
r"""
99+
git\s+push\s+ # git push command
100+
.* # any flags/args
101+
\s-[a-zA-Z]*f # short flag containing -f
102+
""",
103+
re.VERBOSE,
104+
),
105+
),
106+
(
107+
"push_to_main",
108+
"Pushing directly to main/master can corrupt the default branch",
109+
re.compile(
110+
r"""
111+
git\s+push\s+ # git push command
112+
.* # remote name and flags
113+
\s(?:main|master)\b # targeting main or master branch
114+
""",
115+
re.VERBOSE,
116+
),
117+
),
118+
(
119+
"reset_hard",
120+
"git reset --hard discards all uncommitted changes irreversibly",
121+
re.compile(
122+
r"""
123+
git\s+reset\s+ # git reset command
124+
.* # any flags
125+
--hard # hard reset flag
126+
""",
127+
re.VERBOSE,
128+
),
129+
),
130+
(
131+
"clean_force",
132+
"git clean -fd permanently deletes untracked files and directories",
133+
re.compile(
134+
r"""
135+
git\s+clean\s+ # git clean command
136+
.* # any flags
137+
-[a-zA-Z]*f # force flag (required for clean to run)
138+
""",
139+
re.VERBOSE,
140+
),
141+
),
142+
(
143+
"checkout_discard",
144+
"git checkout -- . discards all unstaged changes irreversibly",
145+
re.compile(
146+
r"""
147+
git\s+checkout\s+ # git checkout command
148+
--\s+\. # discard all changes
149+
""",
150+
re.VERBOSE,
151+
),
152+
),
153+
(
154+
"branch_delete_remote",
155+
"Deleting a remote branch can permanently close associated PRs",
156+
re.compile(
157+
r"""
158+
git\s+push\s+ # git push command
159+
\S+\s+ # remote name
160+
--delete\s+ # delete flag
161+
""",
162+
re.VERBOSE,
163+
),
164+
),
165+
(
166+
"branch_delete_remote_colon",
167+
"Deleting a remote branch via :branch syntax can permanently close associated PRs",
168+
re.compile(
169+
r"""
170+
git\s+push\s+ # git push command
171+
\S+\s+ # remote name
172+
:\S+ # :branch (delete syntax)
173+
""",
174+
re.VERBOSE,
175+
),
176+
),
177+
]
178+
179+
# Patterns that should generate warnings (risky but sometimes necessary)
180+
_WARN_PATTERNS: list[tuple[str, str, re.Pattern[str]]] = [
181+
(
182+
"rebase",
183+
"Rebasing rewrites commit history; create a backup branch first",
184+
re.compile(
185+
r"""
186+
git\s+rebase\s+ # git rebase command
187+
""",
188+
re.VERBOSE,
189+
),
190+
),
191+
(
192+
"force_with_lease",
193+
"Force push with lease is safer but still overwrites remote history",
194+
re.compile(
195+
r"""
196+
git\s+push\s+ # git push command
197+
.* # any flags/args
198+
--force-with-lease # safer force push
199+
""",
200+
re.VERBOSE,
201+
),
202+
),
203+
(
204+
"amend_commit",
205+
"Amending commits rewrites history; avoid if already pushed",
206+
re.compile(
207+
r"""
208+
git\s+commit\s+ # git commit command
209+
.* # any flags
210+
--amend # amend flag
211+
""",
212+
re.VERBOSE,
213+
),
214+
),
215+
]
216+
217+
218+
def check_command(command: str) -> list[GitGuardrailViolation]:
219+
"""Check a shell command for git guardrail violations.
220+
221+
Args:
222+
command: The shell command string to validate.
223+
224+
Returns:
225+
List of violations found (empty if command is safe).
226+
"""
227+
if not command or not command.strip():
228+
return []
229+
230+
violations: list[GitGuardrailViolation] = []
231+
232+
for rule, explanation, pattern in _BLOCKED_PATTERNS:
233+
if pattern.search(command):
234+
violations.append(
235+
GitGuardrailViolation(
236+
rule=rule,
237+
severity="block",
238+
command=command,
239+
explanation=explanation,
240+
)
241+
)
242+
243+
for rule, explanation, pattern in _WARN_PATTERNS:
244+
if pattern.search(command):
245+
violations.append(
246+
GitGuardrailViolation(
247+
rule=rule,
248+
severity="warn",
249+
command=command,
250+
explanation=explanation,
251+
)
252+
)
253+
254+
return violations
255+
256+
257+
def has_blocking_violation(command: str) -> bool:
258+
"""Return True if the command contains any blocking git guardrail violation."""
259+
violations = check_command(command)
260+
return any(v.severity == "block" for v in violations)
261+
262+
263+
def format_violations(violations: list[GitGuardrailViolation]) -> str:
264+
"""Format violations into a human-readable message."""
265+
if not violations:
266+
return ""
267+
268+
lines = ["Git guardrail violations detected:"]
269+
for v in violations:
270+
marker = "BLOCKED" if v.severity == "block" else "WARNING"
271+
lines.append(f" [{marker}] {v.rule}: {v.explanation}")
272+
return "\n".join(lines)
273+
274+
275+
# ---------------------------------------------------------------------------
276+
# Token redaction helpers
277+
# ---------------------------------------------------------------------------
278+
279+
# Patterns that match common token/secret formats in commands
280+
_TOKEN_PATTERNS: list[re.Pattern[str]] = [
281+
# GitHub PATs (classic and fine-grained)
282+
re.compile(r"ghp_[A-Za-z0-9]{36,}"),
283+
re.compile(r"github_pat_[A-Za-z0-9_]{36,}"),
284+
# GitLab tokens
285+
re.compile(r"glpat-[A-Za-z0-9\-_]{20,}"),
286+
# Generic Bearer/token in URLs
287+
re.compile(r"(?<=://)([^:]+):([^@]+)@", re.IGNORECASE),
288+
]
289+
290+
291+
def redact_tokens_in_command(command: str) -> str:
292+
"""Redact known token patterns in a command string.
293+
294+
Args:
295+
command: The command string that may contain tokens.
296+
297+
Returns:
298+
Command with tokens replaced by [REDACTED].
299+
"""
300+
result = command
301+
for pattern in _TOKEN_PATTERNS:
302+
if pattern.groups:
303+
# For patterns with groups (like URL credentials), replace the whole match
304+
result = pattern.sub("[REDACTED]@", result)
305+
else:
306+
result = pattern.sub("[REDACTED]", result)
307+
return result

components/runners/ambient-runner/ambient_runner/platform/prompts.py

100644100755
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,48 @@
6868
"the feature branch (`{branch}`). If push fails, do NOT fall back to main.\n\n"
6969
)
7070

71+
GIT_SAFETY_INSTRUCTIONS = (
72+
"## Git Safety Guardrails\n\n"
73+
"You MUST follow these rules when performing git operations. Violations can "
74+
"cause **irreversible data loss** including destroyed PRs, lost review history, "
75+
"and corrupted branches.\n\n"
76+
"### Hard Rules (NEVER violate)\n\n"
77+
"1. **NEVER delete remote branches or refs** — deleting a remote branch "
78+
"permanently closes any associated PR and makes it unrestorable. Do NOT use "
79+
"`git push --delete`, `git push origin :branch`, or "
80+
"`gh api -X DELETE .../git/refs/...`.\n\n"
81+
"2. **NEVER manipulate git refs via the GitHub/GitLab REST API** — if "
82+
"`git push` fails, report the failure to the user and stop. Do NOT "
83+
"circumvent push failures by using `gh api` or `curl` to PATCH/POST/DELETE "
84+
"refs, or to create commits/trees/blobs directly via the Git Data API.\n\n"
85+
"3. **NEVER force push** — do not use `git push --force` or `git push -f`. "
86+
"If you must update a remote branch after a rebase, use "
87+
"`git push --force-with-lease` and ONLY after getting explicit user approval.\n\n"
88+
"4. **NEVER modify the user's default/main branch** — treat `main` and "
89+
"`master` as read-only. Never push commits to them, never reset them, "
90+
"never rebase onto them with a force push.\n\n"
91+
"5. **NEVER run destructive local operations without a backup** — before "
92+
"running `git reset --hard`, `git clean -fd`, `git checkout -- .`, or "
93+
"any rebase, ALWAYS create a backup branch first:\n"
94+
" ```\n"
95+
" git branch backup-$(date +%s)\n"
96+
" ```\n\n"
97+
"6. **NEVER embed tokens or credentials in commands** — do not include "
98+
"PATs, API keys, or passwords in git remote URLs, curl commands, or any "
99+
"shell command. Use environment variables (e.g. `$GITHUB_TOKEN`) instead.\n\n"
100+
"### Escalation Protocol\n\n"
101+
"When a git operation fails, you MUST follow this protocol:\n"
102+
"1. **Stop** — do not retry with a more aggressive variant.\n"
103+
"2. **Diagnose** — read the error message and identify the root cause "
104+
"(auth scope, permissions, branch protection, etc.).\n"
105+
"3. **Report** — tell the user what failed and why.\n"
106+
"4. **Wait** — let the user decide the next step. Do NOT autonomously "
107+
"escalate to force pushes, API workarounds, or destructive operations.\n\n"
108+
"Violating these rules can permanently destroy user work, close PRs, "
109+
"and lose review history. When in doubt, ask the user.\n\n"
110+
)
111+
112+
71113
RUBRIC_EVALUATION_HEADER = "## Rubric Evaluation\n\n"
72114

73115
RUBRIC_EVALUATION_INTRO = (
@@ -215,6 +257,10 @@ def build_workspace_context_prompt(
215257
prompt += f"- **repos/{repo_name}/**\n"
216258
prompt += GIT_PUSH_STEPS.format(branch=push_branch)
217259

260+
# Git safety guardrails (always included when repos are present)
261+
if repos_cfg:
262+
prompt += GIT_SAFETY_INSTRUCTIONS
263+
218264
# Human-in-the-loop instructions
219265
prompt += HUMAN_INPUT_INSTRUCTIONS
220266

0 commit comments

Comments
 (0)