diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 0000000..1e6d9bb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,31 @@
+name: Bug
+description: Something does not work as documented
+title: "[Bug] "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for the report. Do **not** include tokens, `~/.loadpath/settings.json`, or private repo contents.
+ - type: textarea
+ id: expected
+ attributes:
+ label: What happened
+ description: What you did, what you expected, and what you got.
+ validations:
+ required: true
+ - type: textarea
+ id: repro
+ attributes:
+ label: Repro
+ description: Commands, `loadpath.yml` (redacted), and stack if you have one.
+ render: shell
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Version
+ description: `loadpath --version` or git SHA.
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..d03483f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security report
+ url: https://github.com/Modsofthenation/PR-Reviewer/security/advisories/new
+ about: Report a vulnerability privately. Do not file a public issue.
diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml
new file mode 100644
index 0000000..3914224
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature.yml
@@ -0,0 +1,17 @@
+name: Feature
+description: Ask for an extractor, rule, or workflow Loadpath does not have
+title: "[Feature] "
+labels: ["enhancement"]
+body:
+ - type: textarea
+ id: request
+ attributes:
+ label: What do you want
+ description: The load path you cannot inspect today, and why a hunk-comment bot would not be enough.
+ validations:
+ required: true
+ - type: textarea
+ id: stack
+ attributes:
+ label: Stack
+ description: Django / React surfaces involved (DRF, Celery, Next.js, …).
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..baa8f39
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,18 @@
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ - package-ecosystem: pip
+ directory: /
+ schedule:
+ interval: weekly
+ - package-ecosystem: npm
+ directory: /ui
+ schedule:
+ interval: weekly
+ - package-ecosystem: npm
+ directory: /desktop
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fcf16f0..588c13c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,9 @@ on:
branches: [main]
pull_request:
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index d3b1f85..6e7d8b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,9 @@ ui/playwright-report/
ui/test-results/
desktop/backend-dist/
desktop/dist/
+.env.*
+!.env.example
+*.pem
+id_rsa
+id_rsa.pub
+credentials.json
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..4c8d2fa
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,78 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior:
+
+- The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+
+## Enforcement
+
+Report incidents to the repository owner on GitHub (`Modsofthenation`). For
+security issues, use [SECURITY.md](SECURITY.md) instead of a public issue.
+
+All complaints will be reviewed and investigated promptly and fairly.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+1. **Correction** — private warning.
+2. **Warning** — warning with consequences for continued behavior.
+3. **Temporary ban** — temporary ban from community interaction.
+4. **Permanent ban** — permanent ban from community interaction.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
+
+[homepage]: https://www.contributor-covenant.org
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6ebf27b..a5156ee 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,16 +1,24 @@
# Contributing
+By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). Security reports go to [SECURITY.md](SECURITY.md), not the public issue tracker.
+
## Setup
Python 3.12+ and Node 22+.
```bash
+git clone https://github.com/Modsofthenation/PR-Reviewer.git
+cd PR-Reviewer
+python3 -m venv .venv
+source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
python -m playwright install chromium # optional; needed for UI screenshot tests
cd ui && npm install && npm run build && cd ..
loadpath --help
```
+Do not commit `~/.loadpath/`, `.env`, tokens, or private clones. Settings already live outside the repo.
+
## Tests
```bash
diff --git a/README.md b/README.md
index d1351a5..0dab932 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
[](https://github.com/Modsofthenation/PR-Reviewer/actions/workflows/ci.yml)
-Review as **load-path inspection** on a Django + React architecture graph. Not another hunk-comment bot.
+Local **load-path inspection** for Django + React pull requests. A change is a force: Loadpath traces where that force travels until it hits a sink (HTTP, UI, Celery/Dramatiq, migration, permission), then scores whether you have enough evidence to merge.
-A change is a force. Loadpath traces where that force travels until it hits a sink — HTTP response, UI, Celery/Dramatiq job, migration, permission — then scores whether you have enough evidence to merge.
+It is a CLI, a local desktop UI, a GitHub Action merge gate, and an MCP server you can point Cursor at. Not a SaaS. Not a hunk-comment bot.
```
Loadpath: MEDIUM — Invoice.total field change
@@ -21,118 +21,154 @@ On the demo monorepo that path is:
plus the jobs the view enqueues (`send_invoice_email.delay`, `rebuild_ledger.send`).
-
+## What it does
-It is a local CLI, a desktop UI, and an MCP server you can point Cursor at. Not a SaaS. Tokens stay on the machine in `~/.loadpath/settings.json`.
+- Indexes a Django + React repo into a typed architecture graph (AST overlay, not an import graph).
+- Reviews a git range against that graph: sinks, tests, contract drift, auth, suggested reviewers.
+- Shows the same walk in a local UI (2D map, optional 3D when WebGL is available).
+- Optionally upserts **one** PR comment (updated in place) and fails CI on architecture blockers.
+- Speaks MCP so an editor can ask for the brief without dumping the full graph.
-## App
+## What it does not do
-`loadpath serve --port 7345` opens a local desktop-style UI: icon rail, labeled toolbar, merge-box confidence, and an inspectable impact graph. The same process hosts MCP at `/mcp` (OAuth). AI is used **only** for residual uncertainty the graph cannot close. Twenty-four themes live in Settings and `localStorage`. Last repo, git range, SCM slug, and the last review id are remembered the same way. Copy the markdown brief, save HTML, or post **one** PR comment (updated in place) from the Review tab. Keyboard: `1`–`5` switches tabs. `⌘`/`Ctrl`+`K` opens the command palette. `j`/`k` walks read-order. Outside Settings and Pull requests, `⌘`/`Ctrl`+`Enter` runs a review. Click a finding, sink, or inspector neighbor to select it on the graph; Open in editor uses Cursor / VS Code. Watch the working tree to re-walk on save. Architecture edits `loadpath.yml` in place.
+- **Not a general reviewer bot.** It does not comment every hunk, suggest nits, or replace human review.
+- **Not a SaaS.** Nothing is uploaded. Tokens stay in `~/.loadpath/` on the machine that runs Loadpath.
+- **Not a linter, SAST scanner, or test runner.** It does not execute your app or your test suite.
+- **Not a generic call graph.** Other stacks (Rails, JVM, …) are out of scope. FastAPI / GraphQL / HTMX are overlays inside a Django+React repo, not standalone products.
+- **Not a runtime tracer.** Edges come from extractors (AST, OpenAPI, generated clients). Dashed edges are inferred.
+- **Not CodeScene / django-orm-lens / SCIP.** Churn and N+1 heuristics are scored on the load path only.
-### Empty review
+MIT. See [LICENSE](LICENSE). Conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Vulnerabilities: [SECURITY.md](SECURITY.md).
-Until a repo is indexed and a range is walked, Review is an onboarding card — not a blank graph.
+## Requirements
-
-
-### Review
-
-Confidence brief, read-order, clusters, architecture findings on the impact path, residual list, and the subgraph for the git range. Review walks the **indexed** graph (incremental refresh by default).
-
-
-
-### Node inspector
-
-Click a node. The inspector answers *what is this, what feeds it, what does it call, what would break* — not a dump of the indexer row.
-
-
-
-### Architecture
-
-Index a repo first. The architecture tab is the full typed graph plus `loadpath.yml` contexts and rules — not a PR diff. Findings here are repo-wide; review then scopes them to the change.
+- Python **3.12+**
+- Node **22+** (to build the UI; the served app is static files)
+- Git
+- A Django + React repo (or the bundled demo)
-
+## Install
-### Impact graph
+Clone and install from source (there is no PyPI package yet):
-Toggle **This review** (impact subgraph) vs **Indexed architecture** (the repo map). Dashed edges are inferred (URL/Zod overlap); solid edges are extracted or generated-client stitches. 2D is the default. 3D uses the same layout algorithms, puts bounded context on the depth axis, and is available when WebGL is.
+```bash
+git clone https://github.com/Modsofthenation/PR-Reviewer.git
+cd PR-Reviewer
+python3 -m venv .venv
+source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install -e ".[dev]"
+cd ui && npm install && npm run build && cd ..
+loadpath --help
+```
-
+`python -m playwright install chromium` is optional (UI screenshot tests only).
-
+## Quick start (demo)
-### Pull requests
+The fixture at [`fixtures/demo_monorepo`](fixtures/demo_monorepo) is not a git repo. Copy it and make **two** commits so `HEAD~1`…`HEAD` is a real range:
-GitHub, GitLab, and Bitbucket via **Sign in** (OAuth) or a token in Settings. GitHub Enterprise and self-hosted GitLab take a host in Settings. After connecting, **My repos** lists every repository the account can access. Pick one and **Review this PR** — Loadpath fetches the PR refs into a local clone if this machine does not already have one. Review still runs against that clone. The screenshot uses a fixture PR so the tab is not empty.
+```bash
+cp -R fixtures/demo_monorepo /tmp/acme-billing
+cd /tmp/acme-billing
+git init -b main
+git add -A && git commit -m "baseline"
+python3 - <<'PY'
+from pathlib import Path
+p = Path("backend/billing/serializers.py")
+p.write_text(p.read_text().replace(
+ 'fields = ["id", "customer_id", "total", "status"]',
+ 'fields = ["id", "customer_id", "total", "status"]\n extra_kwargs = {"total": {"required": True}}',
+))
+PY
+git add -A && git commit -m "tighten Invoice.total contract"
-
+loadpath index /tmp/acme-billing
+loadpath review /tmp/acme-billing --base HEAD~1 --head HEAD
+loadpath serve --open
+```
-### Repo explorer
+Point the UI at `/tmp/acme-billing` (or pick it in the repo explorer). Default range is `HEAD~1`…`HEAD`.
-Browse the filesystem, pick a project root. Loadpath remembers recent workspaces.
+**Flow:** `index` builds the graph → `architecture` surveys the repo → `review` walks a git range. The app mirrors this: Index, Architecture, Review.
-
+## Run
-### Settings
+### CLI
-Appearance (all 24 themes), GitHub / GitLab / Bitbucket **OAuth sign-in** (or a classic PAT / app password), GitHub Enterprise host, and AI providers (Anthropic, OpenAI, Grok/xAI, DeepSeek, Cursor-compatible, Ollama). Residual analysis only — Loadpath does not comment every hunk.
+```bash
+loadpath init /path/to/repo # draft loadpath.yml (never overwrites)
+loadpath index /path/to/repo # SQLite graph at .loadpath/graph.sqlite3
+loadpath architecture /path/to/repo
+loadpath review /path/to/repo --base HEAD~1 --head HEAD
+loadpath review /path/to/repo --base origin/main --head HEAD --no-reindex
+loadpath review /path/to/repo --dirty # include the working tree
+loadpath review /path/to/repo --fail-on blocker # never | blocker | low | medium
+loadpath whatif /path/to/repo django.field:billing.Invoice.total
+loadpath serve --port 7345 # UI + API + MCP /mcp
+loadpath mcp # stdio MCP for Cursor (no OAuth)
+```
-GitHub uses device flow (`repo read:user read:org`). Create an OAuth App, enable Device Flow, then set `LOADPATH_GITHUB_CLIENT_ID` or paste the client ID in Settings. For GitHub Enterprise, set the host (API is `{host}/api/v3`).
+Put `loadpath.yml` at the repo root (see [`loadpath.yml.example`](loadpath.yml.example)).
-GitLab uses authorization code. Create an OAuth application whose callback is `http://127.0.0.1:7345/api/oauth/gitlab/callback`, then set `LOADPATH_GITLAB_CLIENT_ID` / `LOADPATH_GITLAB_CLIENT_SECRET` (or paste them in Settings). Self-managed GitLab takes a host.
+### Local UI
-Bitbucket uses authorization code. Create an OAuth consumer whose callback is `http://127.0.0.1:7345/api/oauth/bitbucket/callback`, then set `LOADPATH_BITBUCKET_CLIENT_ID` / `LOADPATH_BITBUCKET_CLIENT_SECRET` or paste the key and secret in Settings. Access tokens are refreshed automatically. SCM sign-in and repo listing are local-only (loopback) so a tunneled MCP server does not expose private repos.
+`loadpath serve` binds **127.0.0.1:7345** and opens the app. Icon rail, merge-box confidence, inspectable graph. Copy markdown, save HTML, or post **one** PR comment from Review.
-
+Keyboard: `1`–`5` tabs. `⌘`/`Ctrl`+`K` command palette. `j`/`k` read-order. `⌘`/`Ctrl`+`Enter` runs a review (outside Settings / Pull requests).
-### MCP consent
+### GitHub Action merge gate
-When Cursor (or another client) connects over HTTP MCP, Loadpath does not silently grant access. You get a local consent page: client name, Loadpath issuer URL, optional PIN. Approve or deny. Tokens stay in `~/.loadpath/oauth.json`.
+```yaml
+permissions:
+ contents: read
+ pull-requests: write # only needed when comment: true
+jobs:
+ loadpath:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: Modsofthenation/PR-Reviewer@main
+ with:
+ fail-on: blocker # never | blocker | low | medium
+ comment: true
+```
-
+Indexes the checkout, reviews `origin/$GITHUB_BASE_REF...HEAD`, optionally upserts the brief, fails on architecture blockers (or on low/medium confidence if you ask).
-## Themes
+Outputs: `level`, `passed`, `title`, `contract_break` (`none` | `additive` | `breaking` | `drift`).
-Default is **Obsidian**. Settings lists every palette; the shots below are the same Review screen under nine of them.
+CLI equivalent: `loadpath review . --fail-on blocker --comment --provider github --pr $N --repo $SLUG`.
-
-
-Obsidian
 |
-Nord
 |
-Neon noir
 |
-
-
-Synthwave
 |
-Phosphor
 |
-Paper
 |
-
-
-Sakura
 |
-Citrus
 |
-High contrast
 |
-
-
+### MCP (Cursor, Claude, ChatGPT, Gemini)
-Also shipping: Solarized Dark/Light, Forest, Rose Pine, Midnight Amber, Volcano, Lavender, Aurora, Biolume, Carbon, Seafoam, Peach Fuzz, Cotton Candy, Clear Sky, Coral Reef. High contrast is a first-class theme, not an afterthought.
+**Local stdio** — `~/.cursor/mcp.json` or project `.cursor/mcp.json`:
-## Install
+```json
+{
+ "mcpServers": {
+ "loadpath": {
+ "command": "loadpath",
+ "args": ["mcp"]
+ }
+ }
+}
+```
-Python 3.12+ and Node 22+ (UI).
+**HTTP + OAuth** — `loadpath serve` exposes Streamable HTTP MCP at `/mcp`. Cloud hosts need HTTPS. Set `--public-url` when tunneling, and `--oauth-pin` so random clients cannot approve themselves:
```bash
-pip install -e ".[dev]"
-python -m playwright install chromium # optional, for UI screenshot tests
-cd ui && npm install && npm run build && cd ..
-loadpath --help
+loadpath serve --host 0.0.0.0 --port 7345 --public-url https://your-tunnel.example --oauth-pin 123456
```
-See [CONTRIBUTING.md](CONTRIBUTING.md) for tests, screenshot regeneration, and the Electron app.
+The UI remains on `http://127.0.0.1:7345`. The tunnel is for MCP only. First connect opens a consent page on this machine.
-## Desktop app (Windows, macOS, Linux)
+Tools: `list_workspaces`, `init_repo`, `index_repo`, `architecture`, `review`, `detect_repo`, `list_pull_requests`, `list_remote_repositories`, `post_review_comment`, `what_if`, `review_pull_request`, `load_path_marks`, `list_reviews`, `save_config`. `review` returns the brief — not hunk comments. `load_path_marks` feeds the Cursor/VS Code gutter in [`editors/vscode`](editors/vscode).
-Electron wraps the same local app: it starts the Loadpath backend and opens it in a native window. Tokens still live in `~/.loadpath/settings.json`.
+### Desktop app (Windows, macOS, Linux)
-**From source**
+Electron wraps the same local server. Tokens still live in `~/.loadpath/settings.json`.
```bash
pip install -e .
@@ -140,116 +176,82 @@ cd ui && npm install && npm run build && cd ..
cd desktop && npm install && npm start
```
-Requires Python 3.12+ on `PATH` (`python` on Windows, `python3` elsewhere), or set `LOADPATH_PYTHON`.
-
-**Installers**
+Requires Python 3.12+ on `PATH` (`python` on Windows, `python3` elsewhere), or `LOADPATH_PYTHON`.
-GitHub → Actions → **Desktop builds** → **Run workflow**. That manual job builds:
+Installers: GitHub → Actions → **Desktop builds** → **Run workflow** (Linux AppImage/`.deb`, Windows NSIS, macOS unsigned `.dmg`/`.zip`). Gatekeeper needs right-click → Open on macOS.
-| OS | Artifact |
-| --- | --- |
-| Linux | AppImage and `.deb` |
-| Windows | NSIS `.exe` |
-| macOS | `.dmg` and `.zip` (unsigned) |
+## Security
-macOS Gatekeeper will block the unsigned app until you open it from Finder with right-click → Open. The workflow smokes `/api/health` on the bundled Python sidecar before packaging.
+- Default bind is loopback. `/api/*` except `/api/health` rejects non-local `Origin`/`Host` (settings, filesystem browse, review, PR comments, AI).
+- SCM OAuth, tokens, and indexes stay in `~/.loadpath/` (`0700` / `0600`).
+- Do not set `LOADPATH_OAUTH_AUTO_APPROVE=1` outside tests.
+- Report vulnerabilities privately: [SECURITY.md](SECURITY.md).
-## CLI
+## The app
-```bash
-# Detect Django/React roots and draft loadpath.yml (never overwrites an existing file)
-loadpath init /path/to/repo
+Until a repo is indexed and a range is walked, Review is an onboarding card.
-# Index a monorepo (SQLite graph at .loadpath/graph.sqlite3; unchanged hashes skip extract)
-loadpath index /path/to/repo
+
-# Inspect bounded contexts, rules, and type counts from that index
-loadpath architecture /path/to/repo
+
-# Review a git range against the index (three-dot / merge-base by default)
-loadpath review /path/to/repo --base HEAD~1 --head HEAD
-loadpath review /path/to/repo --base origin/main --head HEAD --no-reindex
-loadpath review /path/to/repo --dirty # include the working tree
-loadpath review /path/to/repo --fail-on blocker # CI merge gate (never|blocker|low|medium)
+**Node inspector** — click a node: what it is, what feeds it, what it calls.
-# Walk sinks from one indexed node — no git range
-loadpath whatif /path/to/repo django.field:billing.Invoice.total
+
-# Cross-platform app (API + visual graph + PR list + MCP /mcp with OAuth)
-loadpath serve --port 7345
+**Architecture** — full typed graph plus `loadpath.yml` contexts. Findings here are repo-wide; review scopes them to the change.
-# Local stdio MCP for Cursor / Claude Desktop (no OAuth)
-loadpath mcp
-```
+
-**Flow:** `index` builds the architecture graph → `architecture` shows contexts and rule hits on the whole repo → `review` walks that same graph for a git range. The app mirrors this: Index registers a workspace, Architecture inspects it, Review traces a change through it.
+**Impact graph** — **This review** vs **Indexed architecture**. Dashed edges are inferred; solid edges are extracted. 2D is the default. 3D uses the same layouts with bounded context on the depth axis when WebGL is available.
-The bundled demo checkout is [`fixtures/demo_monorepo`](fixtures/demo_monorepo) (Django billing API + React invoice UI). Copy it and make **two** commits so `HEAD~1` is a real range — the fixture itself is not a git repo, and a single commit leaves the default range empty.
+
-```bash
-cp -R fixtures/demo_monorepo /tmp/acme-billing
-cd /tmp/acme-billing
-git init -b main
-git add -A && git commit -m "baseline"
-# Same contract tweak the screenshots use:
-python3 - <<'PY'
-from pathlib import Path
-p = Path("backend/billing/serializers.py")
-p.write_text(p.read_text().replace(
- 'fields = ["id", "customer_id", "total", "status"]',
- 'fields = ["id", "customer_id", "total", "status"]\n extra_kwargs = {"total": {"required": True}}',
-))
-PY
-git add -A && git commit -m "tighten Invoice.total contract"
-loadpath index /tmp/acme-billing
-loadpath serve --open
-```
+
-Then point the UI at `/tmp/acme-billing`, or pick it from the repo explorer. `loadpath serve` always boots the app; it does not take a repo path. Default range is `HEAD~1`…`HEAD`. Toggle **Include uncommitted** to walk the working tree. Click a node → **What if this changes** to walk sinks as if that node changed — no git range, and **Back to git range** restores the last real review. Isolate path to sinks only filters the current map. The read-order list is a guided tour (prev/next highlights the file on the graph).
+**Pull requests** — GitHub, GitLab, Bitbucket via **Sign in** (OAuth) or a token. GitHub Enterprise and self-hosted GitLab take a host in Settings. **Review this PR** fetches refs into a local clone. Sign-in and repo listing are loopback-only.
-## GitHub Action merge gate
+
-Use this repo as a composite action. It indexes the checkout, reviews `origin/$GITHUB_BASE_REF...HEAD`, optionally upserts the single Loadpath brief, and fails the job on architecture blockers (or on low/medium confidence if you ask).
+**Repo explorer** — pick a project root. Loadpath remembers recent workspaces.
-```yaml
-- uses: Modsofthenation/PR-Reviewer@main
- with:
- fail-on: blocker # never | blocker | low | medium
- comment: true
-```
+
-Outputs: `level`, `passed`, `title`, `contract_break` (`none` | `additive` | `breaking` | `drift`).
+**Settings** — 24 themes; GitHub device flow (`repo read:user read:org`); GitLab / Bitbucket authorization-code (callback `http://127.0.0.1:7345/api/oauth//callback`); AI providers (Anthropic, OpenAI, Grok/xAI, DeepSeek, Cursor-compatible, Ollama) for **residual uncertainty only**.
-CLI equivalent: `loadpath review . --fail-on blocker --comment --provider github --pr $N --repo $SLUG`. `--github-output` (or `GITHUB_OUTPUT`) writes the same fields for Actions.
+Set `LOADPATH_GITHUB_CLIENT_ID` (enable Device Flow on the OAuth App) or paste the client ID in Settings. GitLab/Bitbucket: `LOADPATH_*_CLIENT_ID` / `LOADPATH_*_CLIENT_SECRET`.
-## MCP (Cursor, Claude, ChatGPT, Gemini)
+
-`loadpath serve` exposes Streamable HTTP MCP at `/mcp`, protected with OAuth 2.1 (PKCE, dynamic client registration, Client ID Metadata Documents). Cloud hosts need HTTPS; set `--public-url` to the public origin when tunneling. `--oauth-pin` adds a PIN on the consent page.
+**MCP consent** — HTTP MCP does not silently grant access. Tokens in `~/.loadpath/oauth.json`.
-```bash
-loadpath serve --host 0.0.0.0 --port 7345 --public-url https://your-tunnel.example --oauth-pin 123456
-```
+
-MCP URL: `https://your-tunnel.example/mcp` (or `http://127.0.0.1:7345/mcp` on the same machine).
+Watch the working tree to re-walk on save. Click a node → **What if this changes** to walk sinks with no git range. Isolate path to sinks filters the current map.
-**Cursor (stdio, local)** — `~/.cursor/mcp.json` or project `.cursor/mcp.json`:
+### Themes
-```json
-{
- "mcpServers": {
- "loadpath": {
- "command": "loadpath",
- "args": ["mcp"]
- }
- }
-}
-```
-
-**Cursor / Claude / ChatGPT / Gemini (HTTP + OAuth)** — add that MCP URL in the host’s connectors. The first connect opens a consent page on the Loadpath machine.
+Default is **Obsidian**. Nine of the twenty-four palettes:
-Tools: `list_workspaces`, `init_repo`, `index_repo`, `architecture`, `review`, `detect_repo`, `list_pull_requests`, `list_remote_repositories`, `post_review_comment`, `what_if`, `review_pull_request`, `load_path_marks`, `list_reviews`, `save_config`. `review` returns the load-path brief (confidence, sinks, reviewers, contract-break, auth, suggested tests, trend, checklist) — not hunk comments. `load_path_marks` is the gutter feed for the Cursor/VS Code extension in [`editors/vscode`](editors/vscode). `review_pull_request` fetches GitHub / GitLab / Bitbucket refs into a local clone first.
+
+
+Obsidian
 |
+Nord
 |
+Neon noir
 |
+
+
+Synthwave
 |
+Phosphor
 |
+Paper
 |
+
+
+Sakura
 |
+Citrus
 |
+High contrast
 |
+
+
-Put `loadpath.yml` at the repo root (see [`loadpath.yml.example`](loadpath.yml.example) and [`fixtures/demo_monorepo/loadpath.yml`](fixtures/demo_monorepo/loadpath.yml)). The tool is opinionated about *your* architecture, not a generic module graph.
+Also shipping: Solarized Dark/Light, Forest, Rose Pine, Midnight Amber, Volcano, Lavender, Aurora, Biolume, Carbon, Seafoam, Peach Fuzz, Cotton Candy, Clear Sky, Coral Reef.
## Django support
@@ -257,55 +259,37 @@ AST is the default extractor. It is a **framework overlay**, not an import graph
| Surface | What Loadpath extracts |
| --- | --- |
-| Models | Fields, FK / M2M / O2O, `on_delete`, string refs (`ForeignKey("accounts.User")`) as residuals |
-| Serializers | `Meta.fields` / `exclude`, declared fields, nested serializers, `SerializerMethodField`, parsed `to_representation` keys, `serializes` edges, queryset-in-serializer flag |
-| Views | DRF ViewSets / APIViews, `serializer_class`, `get_serializer_class` (resolved returns; residual only when unresolved), `permission_classes`, `get_queryset`, `filterset_class`, `authentication_classes`, `pagination_class` |
+| Models | Fields, FK / M2M / O2O, `on_delete`, string refs as residuals |
+| Serializers | `Meta.fields` / `exclude`, nested serializers, `SerializerMethodField`, `serializes` edges |
+| Views | DRF ViewSets / APIViews, `serializer_class`, `permission_classes`, `get_queryset`, … |
| Function views | `@api_view`, `@login_required`, `@csrf_exempt`, … |
-| Django Ninja | `@router.get/post/…` routes and views, `Schema` / `ModelSchema` fields (including nested), response annotation → schema |
-| FastAPI (same repo) | `@app.get/post/…` and Pydantic `BaseModel` (nested annotations) — only when the file imports FastAPI, so Ninja is not stolen |
-| GraphQL | Strawberry `@strawberry.type` / `@strawberry.field` and Graphene `ObjectType` / `Mutation`; client `gql` documents stitch by operation/selection name |
-| Channels | `WebsocketConsumer` subclasses and `path(..., Consumer.as_asgi())` websocket routes |
-| Templates + HTMX | `.html` files, `{% url %}` / include / extends, `hx-get/post/…` stitched to Django routes |
-| Cache / flags / on_commit | `cache.get/set/delete`, waffle-style `flag_is_active`, `transaction.on_commit` as sinks |
-| URLs | `path` / `re_path`, DRF `router.register`, `include()` mount composition (`/api` + `invoices//` → `/api/invoices/{id}`) |
-| Signals | `@receiver`, `signal.connect()` residual, `AppConfig.ready()` residual |
-| Management commands | `BaseCommand` + `handle()`, including `.delay(` / `.send(` enqueue edges |
-| Migrations | `CreateModel` / `AddField` / `RemoveField` / `DeleteModel` / `RunPython` as `destructive_migration` |
-| Tests | `test_*` in `tests.py` / `tests/` as `tested_by` |
-
-### Celery
-
-- `@shared_task`, `@app.task`, `@periodic_task`
-- `celery.Task` subclasses (`run(self, invoice_id)`)
-- Enqueue: `.delay(`, `.apply_async(`
-- Signatures / canvas: `.s(`, `.si(`, `chain` / `group` / `chord` (canvas is a residual; inner signatures are `enqueues` edges)
-- `current_app.send_task("billing.tasks.send_invoice_email")` — residual + inferred enqueue
-- `transaction.on_commit(lambda: task.apply_async(...))` — `SIDE_EFFECT` sink + residual; nested enqueue walked
-- `CELERY_BEAT_SCHEDULE` / `beat_schedule` in settings
-
-### Dramatiq
-
-- `@dramatiq.actor`
-- `dramatiq.GenericActor` subclasses (`perform(self, invoice_id)`)
-- Enqueue: `.send(`, `.send_with_options(` (heuristic: dramatiq import or `actors` / `tasks` module)
-
-Call-site placeholders (`rebuild_ledger.send` in a view) do **not** overwrite the actor definition’s file. The graph keeps `actors.py` / `tasks.py` as the node home.
-
-### Idempotency rule
-
-`celery_tasks_must_be_idempotent_on_model_pk` (alias `async_tasks_must_be_idempotent_on_model_pk`) warns when a Celery **or** Dramatiq task takes a full object payload instead of `pk` / `*_id`. Message names the broker.
-
-### Optional `django.setup()` overlay
-
-AST is enough for review. If you need live `_meta` (db_table, resolved relations), set `boot_django: true` in `loadpath.yml`. Loadpath then imports Django, calls `django.setup()`, and merges model/field nodes. Failures become residuals; the AST graph still stands. Leave it `false` in CI unless the fixture is a bootable project.
+| Django Ninja | `@router.get/post/…`, `Schema` / `ModelSchema` |
+| FastAPI (same repo) | `@app.get/post/…` and Pydantic `BaseModel` — only when the file imports FastAPI |
+| GraphQL | Strawberry / Graphene; client `gql` documents stitch by operation name |
+| Channels | `WebsocketConsumer` and websocket routes |
+| Templates + HTMX | `{% url %}`, `hx-get/post/…` stitched to Django routes |
+| Cache / flags / on_commit | `cache.get/set`, waffle-style flags, `transaction.on_commit` as sinks |
+| URLs | `path` / `re_path`, DRF routers, `include()` composition |
+| Signals | `@receiver`, `signal.connect()` residual |
+| Management commands | `BaseCommand` + enqueue edges |
+| Migrations | `CreateModel` / `AddField` / `RemoveField` / `DeleteModel` / `RunPython` |
+| Tests | `test_*` as `tested_by` |
+| Celery | `@shared_task` / `@app.task`, `.delay(` / `.apply_async(`, canvas residual, beat schedule |
+| Dramatiq | `@dramatiq.actor`, `.send(` / `.send_with_options(` |
+
+Call-site placeholders do **not** overwrite the actor definition’s file.
+
+`celery_tasks_must_be_idempotent_on_model_pk` (alias `async_tasks_must_be_idempotent_on_model_pk`) warns when a task takes a full object payload instead of `pk` / `*_id`.
+
+Optional `boot_django: true` in `loadpath.yml` imports Django for live `_meta`. Leave it `false` in CI unless the fixture is bootable. Failures become residuals; the AST graph still stands.
## React + stitch
-**React:** react-router tables, Next.js App Router (`app/**/page.tsx`) and Pages Router, Server Actions, composition, TanStack Query `queryKey` + fetch/axios URL templates, RTK Query `createApi` endpoints, openapi-fetch `client.GET/POST`, tRPC procedures, ts-rest `path:` contracts, Zod schemas, GraphQL codegen types, feature-folder imports, RTL `render()` and Playwright/Cypress `page.goto` / `cy.visit` as `tested_by`.
+**React:** react-router, Next.js App/Pages Router, Server Actions, TanStack Query, RTK Query, openapi-fetch, tRPC, ts-rest, Zod, GraphQL codegen, RTL / Playwright / Cypress as `tested_by`.
-**Stitch (the moat):** OpenAPI from Spectacular/schema files first; generated clients (`generated/`, orval, openapi-typescript) and typed clients (RTK Query, openapi-fetch, ts-rest, tRPC) as high-confidence `consumed_by_client`; FastAPI routes, Ninja/Pydantic schemas, and GraphQL operations (including codegen types) stitch the same way; HTMX URLs and Playwright/Cypress visits match Django/React routes; fallback URL-template matching and serializer/Zod field overlap marked **inferred**.
+**Stitch:** OpenAPI from Spectacular/schema files; generated clients as high-confidence `consumed_by_client`; HTMX and e2e visits match routes; URL-template / Zod overlap marked **inferred**.
-Frontend roots prefer `frontend/src`, `frontend`, `web/src`, `client/src`, `ui/src`, `src-ui/src` — not a Python package `src/` and not `docs` / docs-site trees. `app/` is a Django root candidate.
+Frontend roots prefer `frontend/src`, `frontend`, `web/src`, `client/src`, `ui/src` — not a Python package `src/`. `app/` is a Django root candidate.
## Architecture rules (`loadpath.yml`)
@@ -317,19 +301,17 @@ Frontend roots prefer `frontend/src`, `frontend`, `web/src`, `client/src`, `ui/s
| `no_queryset_in_serializer` | Serializers must not run querysets |
| `celery_tasks_must_be_idempotent_on_model_pk` | Celery and Dramatiq tasks take a model pk |
| `queryset_nplusone` | Loops over querysets that touch related objects need `select_related` / `prefetch_related` |
-| `queryset_missing_index` | `.filter()` / `.order_by()` on a field that has no `db_index` / `unique` |
+| `queryset_missing_index` | `.filter()` / `.order_by()` on a field with no `db_index` / `unique` |
| `cascade_crosses_context` | `on_delete=CASCADE` must not blast into another bounded context |
| `migration_blast_radius` | `RemoveField` / `DeleteModel` still referenced by the typed graph |
| `leaked_seam` | A view queries a model past a query module that already exists in the same context |
-| `tests_bypass_interface` | Tests hit serializer/view internals while the published route or page seam is untested |
-
-Waivers live under `waivers:` in the same file. Reviewers are the `owners` of the bounded contexts in the impact subgraph.
+| `tests_bypass_interface` | Tests hit internals while the published route or page seam is untested |
-Review also scores **depth** on the impact path — the same vocabulary as a deep-module design pass: **module**, **interface**, **seam**, **leverage**, **locality**. A module is deep when a lot of behaviour sits behind a small interface. The **deletion test** asks whether removing a module concentrates complexity or just moves it. The **interface is the test surface**. Architecture is the survey (deepening opportunities ranked Strong / Worth exploring / Speculative); review then scopes those candidates to the git range.
+Waivers live under `waivers:`. Reviewers are the `owners` of bounded contexts on the impact subgraph.
-Impact walk skips permission/app/context hubs, does not climb `renders` into the App shell, and does not follow cross-context `relates_to` (an Invoice FK to UserProfile does not pull identity into a billing review).
+Review also scores **depth** on the impact path (module, interface, seam, leverage, locality) and a **churn & coupling** slice of git history scoped to impact files.
-Review also folds in a **churn & coupling** slice of git history (CodeScene-style hotspots, bus factor, temporal coupling, cyclomatic complexity on the changed functions) scoped to the same impact files — not a whole-repo hotspot map.
+Impact walk skips permission/app/context hubs, does not climb `renders` into the App shell, and does not follow cross-context `relates_to`.
## How confidence is scored
@@ -337,14 +319,12 @@ Line coverage on changed files is the wrong metric. Loadpath scores the **impact
| Signal | High | Low |
| --- | --- | --- |
-| Tests | Sinks in the radius are hit by tests that still reach the changed symbol | Serializer changed, tests only on the view happy path (past the published seam) |
+| Tests | Sinks in the radius are hit by tests that still reach the changed symbol | Serializer changed, tests only on the view happy path |
| Contract | OpenAPI/client types track the serializer | React path/Zod field still old |
| Architecture | No new cross-context edges; published seams hold | `crosses_context` or a leaked queryset seam |
| Graph | Resolved edges | Many inferred/dynamic edges |
-`high` / `medium` / `low` plus three reasons. Isolated leaf UI with green tests and no rule hits is labeled `loadpath:low-risk`. The same PR/range stores a **confidence trend** so a second review on that range can say whether confidence rose, dropped, or the sink count moved.
-
-Auth is a first-class load path: permission_classes, get_queryset object scope, and websocket routes without a gate. Untested seams get **suggested tests** (pytest / RTL / GraphQL / Channels / HTMX). Contract diffs are labeled `additive`, `breaking`, or `drift`.
+`high` / `medium` / `low` plus three reasons. Isolated leaf UI with green tests and no rule hits is `loadpath:low-risk`. Auth is first-class (permission_classes, get_queryset, ungated websockets). Contract diffs are `additive`, `breaking`, or `drift`.
## Tests
@@ -356,30 +336,20 @@ node --test desktop/*.test.mjs
| Suite | What it covers |
| --- | --- |
-| `tests/unit/` | Django/React extractors, architecture rules, depth/seam survey, stitch, overlays (GraphQL/Channels/HTMX/FastAPI), SCM/AI providers |
-| `tests/integration/test_review_vertical_slice.py` | Serializer field change reaches InvoicePage/Zod, not MePage; reviewers `billing-team` |
-| `tests/e2e/test_cli_review.py` | `loadpath index` / `architecture` / `review` markdown, JSON, HTML |
-| `tests/e2e/test_api_flow.py` | health, index, architecture, review-from-index, graph, settings, GitHub + Bitbucket PR list |
-| `tests/e2e/test_mcp_oauth.py` | OAuth metadata/DCR/PKCE, consent, CIMD, MCP `review` stays on the billing load path |
-| `tests/e2e/test_index_architecture_flow.py` | index snapshot, review without index, review walking an existing graph |
-| `tests/e2e/test_brokers_and_django.py` | Celery + Dramatiq sinks, actor-only PR, non-idempotent Dramatiq warning, destructive migration, cross-context blocker, boot overlay, management commands, beat/canvas |
-| `tests/e2e/test_ui_screenshots.py` | Playwright screenshots (tmpdir by default; set `LOADPATH_SCREENSHOT_DIR=docs/screenshots` to regenerate README assets) |
-| `desktop/*.test.mjs` | Electron sidecar command, health-wait, and external-URL allowlist |
-
-To regenerate the README screenshots:
+| `tests/unit/` | Extractors, rules, stitch, SCM/AI providers, loopback API |
+| `tests/integration/test_review_vertical_slice.py` | Serializer field change reaches InvoicePage/Zod, not MePage |
+| `tests/e2e/test_cli_review.py` | `index` / `architecture` / `review` markdown, JSON, HTML |
+| `tests/e2e/test_api_flow.py` | health, index, architecture, review, graph, settings, PRs |
+| `tests/e2e/test_mcp_oauth.py` | OAuth metadata/DCR/PKCE, consent, MCP `review` |
+| `tests/e2e/test_ui_screenshots.py` | Playwright screenshots (`LOADPATH_SCREENSHOT_DIR=docs/screenshots` to regenerate) |
+| `desktop/*.test.mjs` | Electron sidecar, health-wait, external-URL allowlist |
-```bash
-LOADPATH_SCREENSHOT_DIR=docs/screenshots python -m pytest tests/e2e/test_ui_screenshots.py
-```
+See [CONTRIBUTING.md](CONTRIBUTING.md) for screenshot regeneration, Vite, and the editor extension.
## Demo fixture
-[`fixtures/demo_monorepo`](fixtures/demo_monorepo) is a billing/identity split: DRF ViewSet, FBV, Ninja ledger route, FastAPI sidecar gateway, Strawberry + Graphene schema, Channels consumer, Django template + HTMX board, cache keys / feature flags / `on_commit`, Celery tasks + beat + canvas, Dramatiq actor + GenericActor, management command, signal, FK string ref, React InvoicePage/Zod + a `gql` document.
-
-## What this is not
-
-Not CodeRabbit (comments without a closed impact set). Not a CodeScene clone (we do not replace its hotspot maps; we only score churn/coupling on the load path). Not django-orm-lens (we do not boot an ER explorer; we reuse its N+1 / cascade / blast-radius heuristics inside the typed graph). Not a generic SCIP call graph. The product is review as load-path inspection.
+[`fixtures/demo_monorepo`](fixtures/demo_monorepo) is a billing/identity split: DRF ViewSet, FBV, Ninja, FastAPI sidecar, Strawberry + Graphene, Channels, HTMX, Celery + Dramatiq, React InvoicePage/Zod.
## License
-MIT. See [LICENSE](LICENSE). Vulnerability reports: [SECURITY.md](SECURITY.md).
+MIT. See [LICENSE](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
index 7709706..64d1547 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,7 +1,30 @@
# Security
-Please report vulnerabilities privately to the repository owner (`Modsofthenation` on GitHub). Do not open a public issue for unreleased security problems.
+Loadpath is a **local** tool. Tokens, OAuth state, and architecture indexes stay on the machine that runs it (`~/.loadpath/`, mode `0700` / files `0600`). Treat that host as trusted.
-Once this repository is public, enable [GitHub private vulnerability reporting](https://docs.github.com/code-security/security-advisories/working-with-repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository) so researchers can use Security Advisories. The `/security/advisories/new` form 404s until that setting is on.
+## Report a vulnerability
-Tokens and OAuth state live on the machine that runs Loadpath (`~/.loadpath/`); treat that host as trusted. SCM sign-in, disconnect, and `/api/scm/repos` only accept the local Loadpath UI (loopback Origin/Host), so a tunneled MCP server does not list private repositories.
+Please report vulnerabilities **privately**. Do not open a public issue for unreleased security problems.
+
+1. Enable [GitHub private vulnerability reporting](https://docs.github.com/code-security/security-advisories/working-with-repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository) on this repository (required before `/security/advisories/new` works).
+2. Use **Security → Report a vulnerability**, or contact the repository owner (`Modsofthenation` on GitHub).
+
+## What is in scope
+
+- Token or OAuth secret leakage from the local API, UI, MCP server, or GitHub Action
+- Cross-site request forgery against `loadpath serve` while it is running
+- Path traversal or unexpected filesystem writes from `repo_path` / the repo explorer
+- Remote use of stored SCM or AI credentials when the process is bound or tunneled
+
+## What is out of scope
+
+- Findings that require physical or local-user access to `~/.loadpath/`
+- Issues that only apply if you set `LOADPATH_OAUTH_AUTO_APPROVE=1` (that flag skips consent; never use it outside tests)
+- Unsigned desktop installers (macOS Gatekeeper) — tracked as packaging, not a vulnerability
+
+## Hardening notes
+
+- `loadpath serve` defaults to `127.0.0.1`. The HTTP UI (`/api/*` except `/api/health`) refuses non-loopback `Origin` / `Host`.
+- SCM sign-in, settings, filesystem browse, review, and PR comments are local-UI only. A tunneled MCP server does not expose those routes.
+- MCP over HTTP uses OAuth 2.1 (PKCE, consent). Prefer `--oauth-pin` when `--public-url` is set.
+- The GitHub Action interpolates inputs through environment variables, not shell expansion.
diff --git a/action.yml b/action.yml
index 5ea333d..c650d75 100644
--- a/action.yml
+++ b/action.yml
@@ -17,10 +17,6 @@ inputs:
description: "Python version used to install Loadpath."
required: false
default: "3.12"
- extra-args:
- description: "Extra arguments passed to `loadpath review`."
- required: false
- default: ""
outputs:
level:
description: "Confidence level (high, medium, low)."
@@ -51,12 +47,22 @@ runs:
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
+ LOADPATH_FAIL_ON: ${{ inputs.fail-on }}
+ LOADPATH_COMMENT: ${{ inputs.comment }}
+ LOADPATH_PR: ${{ github.event.pull_request.number }}
+ LOADPATH_REPO: ${{ github.repository }}
+ LOADPATH_BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
- ARGS=(review . --base "origin/${GITHUB_BASE_REF:-main}" --head HEAD --fail-on "${{ inputs.fail-on }}")
- if [ "${{ inputs.comment }}" = "true" ] && [ -n "${{ github.event.pull_request.number }}" ]; then
- ARGS+=(--comment --provider github --pr "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}")
+ case "${LOADPATH_FAIL_ON}" in
+ never|blocker|low|medium) ;;
+ *)
+ echo "fail-on must be never, blocker, low, or medium" >&2
+ exit 1
+ ;;
+ esac
+ ARGS=(review . --base "origin/${LOADPATH_BASE_REF:-main}" --head HEAD --fail-on "${LOADPATH_FAIL_ON}")
+ if [ "${LOADPATH_COMMENT}" = "true" ] && [ -n "${LOADPATH_PR}" ]; then
+ ARGS+=(--comment --provider github --pr "${LOADPATH_PR}" --repo "${LOADPATH_REPO}")
fi
- # shellcheck disable=SC2206
- EXTRA=(${{ inputs.extra-args }})
- loadpath "${ARGS[@]}" "${EXTRA[@]}"
+ loadpath "${ARGS[@]}"
diff --git a/editors/vscode/README.md b/editors/vscode/README.md
index 254fe80..4b6f2d0 100644
--- a/editors/vscode/README.md
+++ b/editors/vscode/README.md
@@ -1,6 +1,6 @@
# Loadpath editor gutter
-Marks files on the current Loadpath walk while `loadpath serve` is running on this machine.
+Marks files on the current Loadpath walk while `loadpath serve` is running on this machine (`http://127.0.0.1:7345` by default). The editor talks to the local `/api/marks` route; a tunneled MCP URL is not this feed.
```
code --install-extension /path/to/PR-Reviewer/editors/vscode
diff --git a/pyproject.toml b/pyproject.toml
index 98afc15..3c74b05 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,16 +5,18 @@ build-backend = "setuptools.build_meta"
[project]
name = "loadpath"
version = "0.1.0"
-description = "Architecture-typed impact graphs for Django + React pull requests."
+description = "Architecture-typed impact graphs for Django + React pull requests. Local load-path review, not a hunk-comment bot."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Loadpath" }]
keywords = ["django", "react", "code-review", "architecture", "mcp"]
classifiers = [
+ "Development Status :: 4 - Beta",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Quality Assurance",
+ "Intended Audience :: Developers",
]
dependencies = [
"fastapi>=0.115.0",
@@ -42,6 +44,8 @@ dev = [
Homepage = "https://github.com/Modsofthenation/PR-Reviewer"
Repository = "https://github.com/Modsofthenation/PR-Reviewer"
Issues = "https://github.com/Modsofthenation/PR-Reviewer/issues"
+Documentation = "https://github.com/Modsofthenation/PR-Reviewer#readme"
+"Bug Tracker" = "https://github.com/Modsofthenation/PR-Reviewer/issues"
[project.scripts]
loadpath = "loadpath.cli:app"
diff --git a/src/loadpath/mcp/server.py b/src/loadpath/mcp/server.py
index a78b9bd..b2b4ba0 100644
--- a/src/loadpath/mcp/server.py
+++ b/src/loadpath/mcp/server.py
@@ -3,6 +3,7 @@
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
+from urllib.parse import urlparse
from pydantic import AnyHttpUrl
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -13,7 +14,7 @@
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
from mcp.server.mcpserver import MCPServer
-from mcp.server.transport_security import TransportSecuritySettings
+from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
from loadpath import __version__
from loadpath.mcp import tools
@@ -151,11 +152,36 @@ async def consent(request: Request) -> Response:
return mcp
-def build_mcp_http(mcp: MCPServer):
+def mcp_transport_security(public_url: str | None = None) -> TransportSecuritySettings:
+ """Allow loopback (and an optional tunnel origin). Reject other Host/Origin values."""
+ hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*", "testserver", "testclient"]
+ origins = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"]
+ explicit = (public_url or os.environ.get("LOADPATH_PUBLIC_URL") or "").strip()
+ if explicit:
+ parsed = urlparse(explicit)
+ host = (parsed.hostname or "").lower().strip("[]")
+ if host and host not in {"127.0.0.1", "localhost", "::1"}:
+ hosts.extend([host, f"{host}:*"])
+ if parsed.scheme:
+ origins.extend([f"{parsed.scheme}://{host}", f"{parsed.scheme}://{host}:*"])
+ return TransportSecuritySettings(
+ enable_dns_rebinding_protection=True,
+ allowed_hosts=hosts,
+ allowed_origins=origins,
+ )
+
+
+def mcp_host_allowed(host_header: str, public_url: str | None = None) -> bool:
+ """True when Host is loopback, TestClient, or the optional tunnel hostname."""
+ settings = mcp_transport_security(public_url)
+ return TransportSecurityMiddleware(settings)._validate_host(host_header or "")
+
+
+def build_mcp_http(mcp: MCPServer, public_url: str | None = None):
"""Create the Streamable HTTP Starlette app (initializes session_manager)."""
return mcp.streamable_http_app(
streamable_http_path="/mcp",
- transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
+ transport_security=mcp_transport_security(public_url),
host="0.0.0.0",
)
diff --git a/src/loadpath/server/app.py b/src/loadpath/server/app.py
index 534ce50..1e49352 100644
--- a/src/loadpath/server/app.py
+++ b/src/loadpath/server/app.py
@@ -6,7 +6,8 @@
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import HTMLResponse, Response
+from fastapi.responses import HTMLResponse, JSONResponse, Response
+from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
@@ -20,6 +21,7 @@
build_mcp_http,
copy_mcp_routes,
create_mcp_server,
+ mcp_host_allowed,
mcp_lifespan,
public_base_url,
resource_url,
@@ -234,9 +236,38 @@ def _call_scm(provider: str, fn):
raise HTTPException(502, str(exc)) from exc
+LOCAL_API_ONLY = "This action is only available from the local Loadpath UI"
+
+
def require_loopback(request: Request) -> None:
if not is_loopback_request(request.headers.get("host") or "", request.headers.get("origin") or ""):
- raise HTTPException(403, "This action is only available from the local Loadpath UI")
+ raise HTTPException(403, LOCAL_API_ONLY)
+
+
+class LoopbackAPIMiddleware(BaseHTTPMiddleware):
+ """Keep /api/* on the local UI. MCP OAuth stays on /mcp, /authorize, /token."""
+
+ async def dispatch(self, request: Request, call_next):
+ path = request.url.path.rstrip("/") or "/"
+ if path.startswith("/api/") and path != "/api/health":
+ if not is_loopback_request(request.headers.get("host") or "", request.headers.get("origin") or ""):
+ return JSONResponse({"detail": LOCAL_API_ONLY}, status_code=403)
+ return await call_next(request)
+
+
+class McpHostMiddleware(BaseHTTPMiddleware):
+ """Reject DNS-rebinding Host headers on MCP OAuth and transport paths."""
+
+ def __init__(self, app, public_url: str | None = None):
+ super().__init__(app)
+ self._public_url = public_url
+
+ async def dispatch(self, request: Request, call_next):
+ path = request.url.path.rstrip("/") or "/"
+ if path == "/mcp" or path.startswith("/mcp/") or path in {"/authorize", "/token", "/register", "/revoke", "/consent"}:
+ if not mcp_host_allowed(request.headers.get("host") or "", self._public_url):
+ return JSONResponse({"detail": "Invalid Host header"}, status_code=421)
+ return await call_next(request)
def _attach_loadpath(listed: list[dict[str, Any]], repo_path: str | None) -> list[dict[str, Any]]:
@@ -276,7 +307,7 @@ def create_app(
oauth_pin=oauth_pin,
auto_approve=oauth_auto_approve,
)
- mcp_http = build_mcp_http(mcp)
+ mcp_http = build_mcp_http(mcp, public_url=base)
app = FastAPI(title="Loadpath", version=__version__, lifespan=mcp_lifespan(mcp))
app.state.mcp = mcp
app.state.mcp_http = mcp_http
@@ -290,6 +321,8 @@ def create_app(
allow_headers=["*"],
expose_headers=["WWW-Authenticate", "Mcp-Session-Id", "mcp-session-id"],
)
+ app.add_middleware(LoopbackAPIMiddleware)
+ app.add_middleware(McpHostMiddleware, public_url=base)
@app.get("/api/health")
def health() -> dict[str, str]:
@@ -951,6 +984,12 @@ def serve(
display = "127.0.0.1" if host in {"0.0.0.0", "::", "[::]"} else host
base = public_base_url(host=host, port=port, public_url=public_url)
application = create_app(public_url=base, oauth_pin=oauth_pin)
+ if host in {"0.0.0.0", "::", "[::]"}:
+ print(
+ "Listening on all interfaces. The UI at /api stays loopback-only; "
+ "remote clients should use MCP at /mcp. Prefer --oauth-pin when --public-url is set.",
+ flush=True,
+ )
if open_browser:
webbrowser.open(f"http://{display}:{port}")
uvicorn.run(application, host=host, port=port, reload=False)
diff --git a/tests/e2e/test_mcp_oauth.py b/tests/e2e/test_mcp_oauth.py
index addb234..fa7a510 100644
--- a/tests/e2e/test_mcp_oauth.py
+++ b/tests/e2e/test_mcp_oauth.py
@@ -49,6 +49,9 @@ def test_oauth_metadata_and_mcp_requires_bearer(tmp_path, monkeypatch):
assert prm.json()["resource"].endswith("/mcp")
assert prm.json()["authorization_servers"] == [body["issuer"]]
+ denied = client.post("/mcp", headers={"host": "evil.example"})
+ assert denied.status_code == 421
+
denied = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "ping"})
assert denied.status_code == 401
assert "resource_metadata" in denied.headers.get("www-authenticate", "").lower()
diff --git a/tests/unit/test_scm_oauth.py b/tests/unit/test_scm_oauth.py
index 123bd38..a6f6167 100644
--- a/tests/unit/test_scm_oauth.py
+++ b/tests/unit/test_scm_oauth.py
@@ -239,8 +239,25 @@ def test_github_oauth_start_requires_client_id(tmp_path, monkeypatch):
def test_scm_routes_reject_cross_origin(tmp_path, monkeypatch):
client = _client(tmp_path, monkeypatch)
headers = {"Origin": "https://evil.example"}
+ assert client.get("/api/health", headers=headers).status_code == 200
assert client.get("/api/scm/repos", params={"provider": "github"}, headers=headers).status_code == 403
assert client.get("/api/oauth/status", headers=headers).status_code == 403
+ assert client.get("/api/settings", headers=headers).status_code == 403
+ assert client.get("/api/fs", headers=headers).status_code == 403
+ assert client.get("/api/repos", headers=headers).status_code == 403
+ assert client.put("/api/settings", json={"github_token": "ghp_x"}, headers=headers).status_code == 403
+ assert client.post("/api/index", json={"repo_path": "/tmp"}, headers=headers).status_code == 403
+ assert client.post(
+ "/api/review",
+ json={"repo_path": "/tmp", "base": "HEAD~1"},
+ headers=headers,
+ ).status_code == 403
+ assert client.post(
+ "/api/prs/comment",
+ json={"provider": "github", "repo": "acme/demo", "number": 1, "markdown": "x"},
+ headers=headers,
+ ).status_code == 403
+ assert client.post("/api/ai/residual", json={"review": {}}, headers=headers).status_code == 403
assert client.post("/api/oauth/github/start", headers=headers).status_code == 403
assert client.post("/api/oauth/disconnect", json={"provider": "github"}, headers=headers).status_code == 403
assert client.put(
@@ -270,6 +287,26 @@ def test_loopback_request_helper():
assert not is_loopback_request("tunnel.example", "")
+def test_mcp_transport_security_allows_loopback_and_tunnel():
+ from loadpath.mcp.server import mcp_transport_security
+
+ local = mcp_transport_security("http://127.0.0.1:7345")
+ assert local.enable_dns_rebinding_protection is True
+ assert "127.0.0.1:*" in local.allowed_hosts
+ assert "evil.example" not in local.allowed_hosts
+
+ tunneled = mcp_transport_security("https://loadpath.example")
+ assert "loadpath.example" in tunneled.allowed_hosts
+ assert "https://loadpath.example" in tunneled.allowed_origins
+
+ from loadpath.mcp.server import mcp_host_allowed
+
+ assert mcp_host_allowed("testserver", None)
+ assert mcp_host_allowed("127.0.0.1:7345", None)
+ assert not mcp_host_allowed("evil.example", None)
+ assert mcp_host_allowed("loadpath.example", "https://loadpath.example")
+
+
def test_github_device_rejects_unexpected_verification_url():
import pytest