git-reviewer 2.14.0 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,9 +3,11 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-372-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-379-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
+ **English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
10
+
9
11
  When an agent writes the code, reading it becomes the bottleneck — and the tool
10
12
  built for reading a diff, the pull request, wants a branch, a remote and a push
11
13
  you are not ready to make yet.
@@ -341,6 +343,32 @@ rather than guess when an anchor has vanished.
341
343
  Submitting in the UI writes this alongside the `.txt`. The full schema is in
342
344
  [docs/agent-format.md](docs/agent-format.md).
343
345
 
346
+ ## Use it from inside your agent
347
+
348
+ Installed as a plugin, the agent opens the review itself, waits while you read
349
+ and comment, and works through your comments the moment you press **Submit
350
+ Review**. You never leave the conversation to start it.
351
+
352
+ ```
353
+ /plugin install git-reviewer --marketplace dheerajjha/reviewer
354
+ ```
355
+
356
+ That is Claude Code; then ask to review the changes, or run
357
+ `/git-reviewer:review`. The same skill installs elsewhere:
358
+
359
+ | Agent | Install |
360
+ |---|---|
361
+ | Codex CLI | `codex plugin marketplace add dheerajjha/reviewer`, then `codex plugin add git-reviewer@git-reviewer` |
362
+ | Cursor | **Customize** → **From GitHub Repository** → `dheerajjha/reviewer` |
363
+ | Copilot CLI | `copilot plugin marketplace add dheerajjha/reviewer`, then `copilot plugin install git-reviewer@git-reviewer` |
364
+ | Anything else | `npx skills add dheerajjha/reviewer` |
365
+
366
+ The skill is plain instructions, [skills/review/SKILL.md](skills/review/SKILL.md):
367
+ start `git reviewer` in the background, wait for the submit, then apply every
368
+ comment, finding the code a comment quotes rather than trusting its line
369
+ number. Where an agent cannot leave a command running, it asks you to start
370
+ `git reviewer` yourself and reads the result with `git reviewer export`.
371
+
344
372
  ## HTTP API
345
373
 
346
374
  The UI is a client of this; nothing is hidden from you.
@@ -382,7 +410,7 @@ rather than a public issue.
382
410
 
383
411
  ```bash
384
412
  npm install # 3 dependencies, no build step, ~6MB
385
- npm test # 372 tests
413
+ npm test # 379 tests
386
414
  npm run test:watch
387
415
  npm run test:coverage
388
416
  ```
package/lib/export.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs').promises;
4
4
  const path = require('path');
5
5
 
6
- const simpleGit = require('simple-git');
6
+ const { simpleGit } = require('simple-git');
7
7
 
8
8
  const { buildReviewDocument, filterReviewDocument } = require('./agent');
9
9
  const { readSavedComments, belongsTo } = require('./store');
package/lib/repo.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const path = require('path');
4
4
 
5
- const simpleGit = require('simple-git');
5
+ const { simpleGit } = require('simple-git');
6
6
 
7
7
  /**
8
8
  * Finding the repository a directory belongs to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.14.0",
3
+ "version": "2.14.1",
4
4
  "description": "Review your coding agent's changes like a pull request — locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
5
5
  "author": "Dheeraj Jha",
6
6
  "license": "MIT",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "cors": "^2.8.5",
53
53
  "express": "^4.18.2",
54
- "simple-git": "^3.20.0"
54
+ "simple-git": "^4.0.2"
55
55
  },
56
56
  "man": [
57
57
  "./man/git-reviewer.1"
package/server.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const express = require('express');
4
4
  const cors = require('cors');
5
- const simpleGit = require('simple-git');
5
+ const { simpleGit } = require('simple-git');
6
6
  const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
 
@@ -26,6 +26,39 @@ const { listReviews } = require('./lib/export');
26
26
  const DEFAULT_PORT = 4500;
27
27
  const DEFAULT_HOST = '127.0.0.1';
28
28
 
29
+ // Addresses that only this machine can reach. Bound to one of these, the
30
+ // server installs `loopbackHostOnly`.
31
+ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', 'localhost', '::1']);
32
+
33
+ // The names a browser puts in `Host` for a server it reached on loopback.
34
+ // An IPv6 address keeps its brackets there.
35
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]']);
36
+
37
+ /** The host name in a `Host` header, without the port. */
38
+ function hostnameOf(header) {
39
+ const value = String(header || '').toLowerCase();
40
+ if (value.startsWith('[')) return value.slice(0, value.indexOf(']') + 1);
41
+ return value.split(':')[0];
42
+ }
43
+
44
+ /**
45
+ * Refuse a request addressed to this server by any name but a loopback one.
46
+ *
47
+ * That is DNS rebinding: a page on the attacker's own domain whose name is
48
+ * re-pointed at 127.0.0.1 after it loads. The browser then treats this server
49
+ * as that site, so `Origin` and `Host` agree and `sameOriginOnly` lets the
50
+ * request through. A server reachable only on loopback is only ever honestly
51
+ * addressed by a loopback name. Bound anywhere else (`HOST=0.0.0.0`), its
52
+ * owner has chosen to be reached by other names, and this is not installed.
53
+ */
54
+ function loopbackHostOnly(req, res, next) {
55
+ if (!LOOPBACK_HOSTNAMES.has(hostnameOf(req.headers.host))) {
56
+ return res.status(403).json({ error: 'Not available under this host name' });
57
+ }
58
+
59
+ next();
60
+ }
61
+
29
62
 
30
63
  /** A path inside the repository that names nothing, on disk or at HEAD. */
31
64
  class FileNotFoundError extends Error {
@@ -60,6 +93,8 @@ class FileNotFoundError extends Error {
60
93
  * stop. Omitted means never: a server with no one to tell keeps serving.
61
94
  * @param {number} [options.idleGraceMs] how long to wait after the last tab
62
95
  * leaves before deciding it is not coming back.
96
+ * @param {boolean} [options.loopbackOnly] refuse any request whose `Host` is
97
+ * not a loopback name. `startServer` turns it on when it binds to loopback.
63
98
  * @returns {import('express').Express}
64
99
  */
65
100
  // How far back the range picker offers to go. Long enough to cover the work
@@ -86,12 +121,15 @@ function createApp(options = {}) {
86
121
  onReviewSubmitted = null,
87
122
  handoff = false,
88
123
  onIdle = null,
89
- idleGraceMs = 5000
124
+ idleGraceMs = 5000,
125
+ loopbackOnly = false
90
126
  } = options;
91
127
 
92
128
  const app = express();
93
129
 
130
+ if (loopbackOnly) app.use(loopbackHostOnly);
94
131
  app.use(cors());
132
+ app.use('/api', sameOriginOnly);
95
133
  app.use(express.json({ limit: '10mb' }));
96
134
  app.use(express.static(path.join(__dirname, 'public')));
97
135
 
@@ -660,20 +698,37 @@ function createApp(options = {}) {
660
698
  /**
661
699
  * Refuse a request a browser has marked as coming from somewhere else.
662
700
  *
663
- * `cors()` above is open, deliberately, and that is a settled decision this
664
- * does not reopen. It is a rule for the two endpoints below, which differ
665
- * from every other one in kind: they enumerate directories and name
666
- * projects, rather than answering about a repository whose path the caller
667
- * already had. A browser sends `Origin` on exactly the requests where that
668
- * distinction matters and omits it on same-origin GETs, so the page keeps
669
- * working and a page on another site does not. Anything that is not a
670
- * browser can read these directories without asking this server anyway.
701
+ * Every `/api` route goes through this (#66). Until 2.14.1 only the routes
702
+ * that enumerate directories did, on the reasoning that the rest answer
703
+ * about a repository whose path the caller already had. A path is easy to
704
+ * guess, though, and `cors()` is open, so a page on another site could read
705
+ * any file in a guessed repository. Worse, it could save a comment into the
706
+ * review, which `reviewer export` then hands to the coding agent as an
707
+ * instruction.
708
+ *
709
+ * A browser sends `Origin` on every cross-origin request that could do
710
+ * either, and leaves it out of the page's own same-origin GETs, so the page
711
+ * keeps working. A client that is not a browser sends no `Origin` and gets
712
+ * through: it could read these files without asking this server anyway.
713
+ * Only the host is compared, not the scheme, so the page still works behind
714
+ * a proxy that terminates TLS.
715
+ *
716
+ * This cannot catch DNS rebinding, where `Origin` and `Host` both name the
717
+ * attacker's domain. `loopbackHostOnly` does.
671
718
  */
672
719
  function sameOriginOnly(req, res, next) {
673
720
  const origin = req.get('origin');
674
721
 
675
- if (origin && origin !== `http://${req.headers.host}`) {
676
- return res.status(403).json({ error: 'Not available to another origin' });
722
+ if (origin) {
723
+ let originHost = null;
724
+ try {
725
+ originHost = new URL(origin).host;
726
+ } catch {
727
+ // `Origin: null` (a sandboxed frame, a file:// page) names no host.
728
+ }
729
+ if (originHost !== req.headers.host) {
730
+ return res.status(403).json({ error: 'Not available to another origin' });
731
+ }
677
732
  }
678
733
 
679
734
  next();
@@ -686,9 +741,9 @@ function createApp(options = {}) {
686
741
  * The connection *is* the signal. An open page holds this response open and
687
742
  * closing the page closes it, so there is no heartbeat interval to tune, and
688
743
  * two tabs are two connections rather than one flag that either close
689
- * clears. `sameOriginOnly` is here for the same reason it is on the two
690
- * below: without it a page on another site could hold this open and keep a
691
- * server alive that its owner had finished with.
744
+ * clears. Like every `/api` route it goes through `sameOriginOnly`, which
745
+ * matters here too: without it a page on another site could hold this open
746
+ * and keep alive a server its owner had finished with.
692
747
  *
693
748
  * Nothing happens at the moment a watcher leaves. A reload closes and
694
749
  * reopens within milliseconds, and a dropped connection reopens when
@@ -701,7 +756,7 @@ function createApp(options = {}) {
701
756
  * after having been above it, so `reviewer` whose browser never launched
702
757
  * keeps serving rather than exiting into an empty terminal.
703
758
  */
704
- app.get('/api/alive', sameOriginOnly, (req, res) => {
759
+ app.get('/api/alive', (req, res) => {
705
760
  if (idleTimer) {
706
761
  clearTimeout(idleTimer);
707
762
  idleTimer = null;
@@ -733,7 +788,7 @@ function createApp(options = {}) {
733
788
  });
734
789
 
735
790
  /** List the directories inside one, so a repository can be found by looking. */
736
- app.get('/api/browse', sameOriginOnly, async (req, res) => {
791
+ app.get('/api/browse', async (req, res) => {
737
792
  try {
738
793
  res.json(await browse(req.query.path));
739
794
  } catch (error) {
@@ -750,7 +805,7 @@ function createApp(options = {}) {
750
805
  });
751
806
 
752
807
  /** Repositories opened before now, newest first. */
753
- app.get('/api/recent', sameOriginOnly, async (req, res) => {
808
+ app.get('/api/recent', async (req, res) => {
754
809
  try {
755
810
  // Annotated with what is saved against each, because "3 comments" is
756
811
  // what tells you which of two checkouts you were in the middle of.
@@ -1109,8 +1164,9 @@ function startServer(options = {}) {
1109
1164
  ...appOptions
1110
1165
  } = options;
1111
1166
  // Everything else in `options` -- reviewsDir, sessions, git, handoff and
1112
- // onReviewSubmitted -- goes through to the app.
1113
- const app = createApp(appOptions);
1167
+ // onReviewSubmitted -- goes through to the app. Bound to loopback, the app
1168
+ // also refuses any Host but a loopback name (see `loopbackHostOnly`).
1169
+ const app = createApp({ loopbackOnly: LOOPBACK_ADDRESSES.has(host), ...appOptions });
1114
1170
 
1115
1171
  return new Promise((resolve, reject) => {
1116
1172
  const server = app.listen(port, host, () => {