RepoPilot's core security premise: issue bodies are attacker-controlled input feeding an agent that holds GitHub write permissions. Every design choice below follows from that.
- HMAC SHA-256 signature (
X-Hub-Signature-256) verified with a constant-time compare (hmac.compare_digest) over the raw body. No signature, wrong prefix, or empty secret ⇒ 401. - Idempotency: every delivery is ledgered by
X-GitHub-DeliveryID with a unique constraint; redeliveries are acknowledged but never re-enqueued. - The webhook handler does no work: verify → ledger → enqueue → 202.
Layered, with the assumption that any single layer can fail:
Layer 1 — input sanitization (packages/agent/guardrails/sanitize.py)
- Strips channels invisible to a human reading the issue on GitHub: HTML comments, markdown image alt-text/URLs, zero-width and Unicode tags-block characters (U+E0000–U+E007F), long data URIs.
- Detects instruction-like patterns (ignore-previous, role reassignment, system-prompt probes, agent addressing, close/delete-all commands, exfiltration asks) on both the sanitized and the original text — an instruction visible only pre-sanitization is the highest-signal attack.
- Detection never blocks triage; it sets
injection_risk=high, which forces autonomy down to suggest-only for that run and flags it in the UI.
Layer 2 — prompt structure
- User content appears only inside fenced data blocks (
<issue_data>); every prompt states that fenced content is data, never instructions.
Layer 3 — output allowlist (packages/agent/guardrails/output.py)
- The agent can only emit the five allowlisted action types; anything else is stripped, never "repaired".
- Comments: blocked on external URLs (only
github.com/<this repo>allowed), secret patterns (GitHub/AWS/Slack/OpenAI token shapes), hidden-channel artifacts; @-mentions stripped; 1500-char cap. - Labels: charset allowlist, ≤50 chars, ≤5 labels. Assignees must come from the deterministic CODEOWNERS/commit-frequency suggester.
close_duplicateis always HIGH risk. - On
injection_risk=high, comment actions are dropped entirely — attacker-influenced text is never posted.
Layer 4 — execution gate (packages/github/executor.py)
decide_gate(autonomy, risk): HIGH never auto-executes, at any autonomy level. The executor is the single code path that writes to GitHub.
Verification: 25 adversarial cases + 1 benign control in evals/datasets/redteam.jsonl, run by CI and by pytest; 100% pass is a hard gate. See EVALS.md.
GitHub App permissions: issues read/write, contents read-only, metadata read-only. No code write access, ever. Installation tokens are short-lived (1h), cached with a refresh margin, never persisted.
Env-only via pydantic SecretStr (excluded from repr/logs). Pre-commit runs detect-private-key. Langfuse traces record token counts, not prompt secrets; the GitHub App PEM never leaves settings.
- Per-run cost cap (default $0.02) bounds blast radius of any prompt-injected "do more work" attack; max one strong-model escalation per run.
Implemented via slowapi (Starlette-native, limits library), backed by Redis in production and in-memory in local dev. Key function prefers X-User-Login (dashboard routes) then client IP (webhooks).
| Route group | Limit | Key |
|---|---|---|
POST /webhooks/github |
300 / minute | client IP |
| Dashboard reads (runs, repos, analytics, actions list/detail) | 120 / minute | user login |
| Mutations (approve, reject, settings PATCH) | 30 / minute | user login |
GET /runs/{id}/stream |
10 / minute | user login |
Over-limit requests receive 429 with a Retry-After header. The middleware is registered in create_app() alongside a custom RateLimitExceeded handler; SlowAPIMiddleware wires into the ASGI stack before routing.
X-User-Login (the signed-in GitHub login forwarded by the BFF) is resolved to a frozenset of repo IDs via scoped_repo_ids(db, caller):
Installation(account_login == caller.login) → Repo.id set
Every gated route — list_runs, get_run, stream_run, list_repos, update_settings, list_pending, approve_action, reject_action, and all analytics aggregates — applies this filter before returning or mutating any data.
Out-of-scope resource access returns 404 (not 403) to avoid existence disclosure. When no X-User-Login is present (local dev with the internal-key gate open), scoped_repo_ids returns None, which bypasses all filtering and preserves the no-auth local UX.
Body-size cap (SecurityMiddleware):
Content-Lengthchecked on every request before the body is read.- Webhook path: 2 MB ceiling. All other paths: 256 KB ceiling.
- Chunked bodies without
Content-Lengthare rejected afterawait request.body()by an explicit byte-length guard inside the webhook handler. - Oversized requests → 413 Request Entity Too Large.
Timing-safe key compare: require_internal uses hmac.compare_digest instead of !=, preventing timing-oracle attacks on the internal API key.
CORS: CORSMiddleware locked to settings.web_origin; only GET, POST, PATCH allowed; exposed headers limited to X-Internal-Key, X-User-Login, Content-Type.
Trusted host: TrustedHostMiddleware active in production only (*.fly.dev).
Security response headers (set on every API response):
| Header | Value |
|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
no-referrer |
Permissions-Policy |
geolocation=(), microphone=(), camera=() |
X-Robots-Tag |
noindex, nofollow |
Server |
(removed) |
Next.js security headers (all routes via next.config.ts): HSTS, nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, and a full Content Security Policy tuned for WebGL, Fontshare CDN, GitHub avatar CDN, and the SSE stream endpoint.
Global exception handler: unhandled exceptions return opaque {"detail": "internal server error"} (500) to clients; full traceback logged via structlog.
API docs: /docs and /redoc are disabled in production.
Prevents a compromised or malicious installation from driving unbounded LLM spend by mass-opening issues.
- Budget key:
budget:{installation_id}:{YYYYMMDD}in Redis (same arq pool reused). - On each
issueswebhook: atomicINCRon the budget key; TTL set to 26 h on first write (covers timezone skew at day boundary). - If the counter exceeds
settings.max_runs_per_install_per_day(default 500):- Delivery status set to
THROTTLEDand persisted to the ledger. - Job not enqueued — zero LLM spend.
- GitHub receives 202 Accepted (prevents exponential retry backoff from GitHub's delivery system).
- Warning logged via structlog with
installation_idanddate_key.
- Delivery status set to
- Budget resets automatically at day rollover (key expiry + new key).
- Cap is env-configurable (
MAX_RUNS_PER_INSTALL_PER_DAY).
Found something? Open a private security advisory on the repo or email the maintainer. Please do not open public issues for vulnerabilities — ironic as that would be for this project.