Run skylos . --diff-base origin/main --danger --quality on the branch: a deleted @login_required, CSRF, rate-limit or validation check comes back as SKY-L021. Not every Skylos mode reports it, though; the table below shows which ones do in 4.47.1.
If you review enough AI-generated pull requests, you see the same pattern. The code is clean, the refactor is plausible, the diff is large, and the vulnerability is a thing that is no longer there:
- a deleted
@login_required - a missing permission branch
- input validation that no longer runs
- rate limiting that vanished during route cleanup
- a middleware registration that quietly dropped out
That is why AI PR review needs a regression mindset, not just an issue-spotting one.
Which commands report a removed control (4.47.1)
The test branch removes @login_required (and its import) from a Django view and changes nothing else:
-from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from app.export import export_rows
-@login_required
def export_customer(request, customer_id):
return JsonResponse({"rows": export_rows(customer_id)})
| Command, run on the branch | SKY-L021 reported? | Exit code |
|---|---|---|
skylos . --diff-base main --danger --quality | Yes | 0 |
skylos . -a --diff-base main | Yes | 0 |
skylos . --diff-base main --danger --quality --gate | Yes | 0 (the default gate doesn't fail on it) |
skylos agent pre-commit (change staged, not committed) | Yes | 1 (commit refused) |
skylos . --danger --quality (change still uncommitted) | Yes | 0 |
skylos . --danger --quality (change committed) | No | 0 |
The same scan with --diff main instead of --diff-base main | No | 0 |
skylos . --diff-base main --diff main ... (what skylos cicd init generates) | No | 0, and skylos cicd gate passed |
skylos done --base main | No | 0, Verdict: PASS |
Two reasons. --diff keeps only findings on changed lines, and a deleted decorator leaves no changed line behind. And a plain scan only diffs uncommitted and staged changes against HEAD; once the change is committed, it has nothing to compare. The 4.47.2 development build gave the same results.
This is what the --diff-base scan printed in --format concise (dead-code lines omitted):
app/views.py:5 SKY-L021 Security control regression: Auth decorator @login_required was removed
What SKY-L021 recognizes
The rule compares the removed and added lines of a diff. It skips test files, and every finding is reported as SKY-L021 (HIGH) with the message prefix "Security control regression:".
| Control | Change that triggers it | What Skylos reports |
|---|---|---|
| Auth decorators | removing @login_required, @permission_required, @permissions_required, @require_auth, @requires_auth, @authenticated, @jwt_required or @token_required | Auth decorator @login_required was removed |
| FastAPI auth dependencies | removing Depends(get_current_user), Depends(get_current_active_user), Depends(require_admin) or Depends(verify_token) without adding it back | Auth dependency Depends(get_current_user) was removed |
| Express auth middleware | removing middleware such as requireAuth, isAuthenticated, verifyToken or passport.authenticate from a route that still exists | Auth middleware 'requireAuth' was removed from app.get('/export') |
| CSRF | removing @csrf_protect, CSRFProtect or CsrfViewMiddleware, or adding @csrf_exempt | CSRF protection 'csrf_protect' was removed |
| Rate limiting | removing @rate_limit, @ratelimit, @throttle or @limiter.limit | Rate limiting decorator @rate_limit was removed |
| TLS verification | adding verify=False, especially where verify=True was removed | TLS verification downgraded from verify=True to verify=False |
| Hash strength | replacing sha256, sha512, bcrypt, argon2, scrypt or pbkdf2 with md5 or sha1 | Crypto downgraded from sha256 to md5 |
| Validation | removing @validate, @validator, @field_validator, @validates or @validates_schema, a validate( or sanitize( call, or a Django form or DRF serializer validator | Validation decorator @field_validator was removed |
| Security headers | removing Content-Security-Policy, Strict-Transport-Security, X-Frame-Options or another named security header, or SecurityMiddleware, helmet( or secure_headers | Security header 'Content-Security-Policy' was removed |
| Encryption | removing Fernet, AES, encrypt( or decrypt( calls, or a SECRET_KEY = assignment | Encryption call was removed |
| Audit logging | removing @audit, @log_access or an audit_log( call | Audit/logging call was removed |
| Sanitization | removing html.escape(, bleach.clean(, markupsafe.escape( or DOMPurify.sanitize( without adding it back | Sanitization call was removed |
| Permission checks | removing has_permission(, check_permission(, has_perm(, user_passes_test, @permission_classes or @has_role | Permission check was removed |
Names outside these lists are not recognized. A custom decorator such as @staff_only needs a test or a manual review step.
Run it on every pull request
This job fails the pull request when the scan reports a removed control. Run locally against a clone with GITHUB_BASE_REF=main, the scan-and-grep step exited 1 on the removal branch and 0 on main:
name: skylos-removed-controls
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
removed-controls:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- run: python -I -m pip install "skylos==5.1.1"
- name: Fail on removed security controls (SKY-L021)
run: |
skylos . --diff-base "origin/$GITHUB_BASE_REF" --danger --quality --format json -o skylos.json
if grep -q '"SKY-L021"' skylos.json; then exit 1; fi
--gate on its own would not fail this job: SKY-L021 is counted as a quality finding, and the default gate allows up to 10 of those. The last line fails the job whenever the scan reports a removed control. fetch-depth: 0 makes the base branch available for --diff-base. Make the job a required status check.
If you also use skylos cicd init --no-upload, keep this job alongside the generated "Skylos Quality Gate" and "Skylos Done" jobs. The generated gate scans changed lines (--diff), so on its own it does not fail a PR for a removed decorator in 4.47.1.
Block it before the commit
For changes made on a developer's machine, including by an agent, skylos agent pre-commit checks the staged files and refuses the commit:
# .git/hooks/pre-commit (chmod +x)
#!/bin/sh
exec skylos agent pre-commit
Commit check: reviewing 1 source staged file(s). Checks security, secrets, and
high-signal quality regressions on production source/config.
1 issue(s) found in staged files (1 quality):
HIGH app/views.py:5 Security control regression: Auth decorator
@login_required was removed
git commit --no-verify skips any pre-commit hook, so treat this as fast feedback and keep the CI job. The agent hooks guide has a PreToolUse hook that refuses --no-verify in Claude Code.
Review regressions before additions
In AI-generated PRs, deleted protections deserve priority over newly added low-severity findings:
- removed auth or permission logic
- removed validation or output safety
- removed rate limiting, CSRF, or middleware
- newly introduced dangerous sinks
- dead code and cleanup noise
That ordering keeps a serious removal from getting lost in 200 lines of helper extraction.
Review questions that match the failure mode:
- What existing protection disappeared in this refactor?
- Did any route or handler lose auth, permission, or rate-limit enforcement?
- Did validation move in a way that now skips some code paths?
- Did middleware or settings registration change?
- Is any deleted code required for security or auditability?
Removed tests are a regression too
The same refactor can delete or weaken the tests that would have caught it. skylos done --base origin/main flags deleted, skipped and assertion-free tests, loosened pytest settings and CI test steps, and test-only special cases (how it works). It does not report removed auth checks, so run it alongside the scan above, not instead of it.
When SKY-L021 fires
Restore the control. If the removal is intended (the route moved behind middleware, say), record why in the PR and suppress that line with # skylos: ignore[SKY-L021]. Reviewers then see a decision instead of a silent deletion.
Treat the PR as high-risk if it also:
- rewrites auth, billing, admin, or export flows
- touches decorators, middleware, or route registration
- replaces several files with one "simplified" abstraction
- changes request parsing or serializer behavior
- removes tests while refactoring the protected path
What it does not catch
SKY-L021 only knows the named patterns in the table above. It does not see a tenant filter dropped from a query, business authorization rules, CORS policy, or arbitrary middleware behavior. Cover those with tests that exercise the protected path as an unauthorized user, and with manual review of any diff that touches auth, permissions or request handling.
Where to go next
- Claude Code hooks examples for security
- Verify AI-generated code: check the agent didn't delete or skip tests
- Claude Code security review with hooks
- Cursor security scanner for Python
- GitHub Copilot code review and security
- AI code review security checklist for pull requests
- GitHub Actions PR gate for AI-generated code
Running this across a team? These checks are free in the CLI. Skylos Cloud keeps the results in one place: the Free plan adds a pass/fail check on pull requests and scan history for one project, and the Workspace plan adds up to 10 projects, approvals for findings you decide not to fix, pull request comments, and audit exports.