@abhaydesu/git-pilot 1.3.0 → 1.3.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
@@ -85,8 +85,17 @@ Check the installed version with `git pilot --version`, or see all commands with
85
85
  | --- | --- | --- |
86
86
  | `GIT_PILOT_API_URL` | `https://git-pilot-api.vercel.app` | Base URL of the backend API. Set it to use a self-hosted or local instance, e.g. `GIT_PILOT_API_URL=http://localhost:3000 git pilot run "show status"`. |
87
87
 
88
+ `GIT_PILOT_API_URL` must use `https` (plain `http` is accepted only for `localhost`), because your staged diffs are sent to that server.
89
+
88
90
  Very large staged diffs (over ~800,000 characters) are not sent in full. Git Pilot sends the list of changed files instead and uses your intent to write the message.
89
91
 
92
+ ### ◾ Privacy and Security
93
+
94
+ * `git pilot commit` sends your **staged diff** (and intent) to the Git Pilot API, which forwards it to Google's Gemini API. `run` sends your request text, `undo` your last 15 reflog lines, and `branch` your description. **Don't stage secrets or code you can't share** when using `commit`.
95
+ * Suggested commands are validated locally before you are even asked to confirm: only `git` with a known subcommand (`add`, `commit`, `push`, ...), no shell operators, no leading global options (`git -c ...`), and no options that run other programs (`--upload-pack`, `--exec`, ...).
96
+ * Commands run without a shell, and only after you confirm.
97
+ * To report a vulnerability, please open a private security advisory on the GitHub repository rather than a public issue.
98
+
90
99
  ### ◾ Development
91
100
 
92
101
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abhaydesu/git-pilot",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "AI-powered Git assistant: commit messages, natural-language git commands, undo suggestions and branch names.",
5
5
  "main": "src/cli.js",
6
6
  "scripts": {
@@ -25,7 +25,7 @@
25
25
  "license": "MIT",
26
26
  "preferGlobal": true,
27
27
  "dependencies": {
28
- "axios": "^1.12.2",
28
+ "axios": "^1.20.0",
29
29
  "chalk": "^5.6.2",
30
30
  "commander": "^14.0.1",
31
31
  "execa": "^9.6.0",
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { callApi } from "../lib/api.js";
2
+ import { callApi, requireString } from "../lib/api.js";
3
3
  import * as git from "../lib/git.js";
4
4
  import {
5
5
  spin,
@@ -14,7 +14,7 @@ import {
14
14
  export async function branchCommand(description) {
15
15
  const spinner = spin("Generating a conventional branch name...");
16
16
  try {
17
- const { branchName } = await callApi("pilot-branch", { description });
17
+ const branchName = requireString(await callApi("pilot-branch", { description }), "branchName");
18
18
  spinner.succeed();
19
19
 
20
20
  showBlock("Suggested Branch Name", branchName);
@@ -1,4 +1,4 @@
1
- import { callApi, MAX_DIFF_CHARS } from "../lib/api.js";
1
+ import { callApi, fitsInRequest, requireString } from "../lib/api.js";
2
2
  import * as git from "../lib/git.js";
3
3
  import {
4
4
  spin,
@@ -23,11 +23,13 @@ export async function commitCommand(intent) {
23
23
  return;
24
24
  }
25
25
 
26
- const diffToSend =
27
- diff.length > MAX_DIFF_CHARS ? OVERSIZED_PREFIX + (await git.getStagedSummary()) : diff;
26
+ let payload = { intent, diff };
27
+ if (!fitsInRequest(payload, diff)) {
28
+ payload = { intent, diff: OVERSIZED_PREFIX + (await git.getStagedSummary()) };
29
+ }
28
30
 
29
31
  spinner.text = "Generating commit message...";
30
- const { message } = await callApi("pilot-commit", { intent, diff: diffToSend });
32
+ const message = requireString(await callApi("pilot-commit", payload), "message");
31
33
  spinner.succeed();
32
34
 
33
35
  showBlock("Suggested Message", message);
@@ -1,12 +1,12 @@
1
1
  import chalk from "chalk";
2
- import { callApi } from "../lib/api.js";
2
+ import { callApi, requireString } from "../lib/api.js";
3
3
  import { parseGitCommand, runGit } from "../lib/exec.js";
4
4
  import { spin, showBlock, confirm, success, aborted, handleError } from "../lib/ui.js";
5
5
 
6
6
  export async function runCommand(request) {
7
7
  const spinner = spin("Fetching the right command...");
8
8
  try {
9
- const { command } = await callApi("pilot-run", { request });
9
+ const command = requireString(await callApi("pilot-run", { request }), "command");
10
10
 
11
11
  if (command.startsWith("Error:")) {
12
12
  spinner.fail(command);
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { callApi } from "../lib/api.js";
2
+ import { callApi, requireString } from "../lib/api.js";
3
3
  import { parseGitCommand, runGit } from "../lib/exec.js";
4
4
  import * as git from "../lib/git.js";
5
5
  import { spin, showBlock, confirm, success, aborted, handleError } from "../lib/ui.js";
@@ -10,7 +10,8 @@ export async function undoCommand() {
10
10
  const reflog = await git.getReflog();
11
11
 
12
12
  spinner.text = "Suggesting an undo command...";
13
- const { command, explanation } = await callApi("pilot-undo", { reflog });
13
+ const data = await callApi("pilot-undo", { reflog });
14
+ const command = data?.command ?? null;
14
15
 
15
16
  if (!command) {
16
17
  spinner.warn("Couldn't determine a safe action to undo.");
@@ -18,10 +19,10 @@ export async function undoCommand() {
18
19
  }
19
20
  spinner.succeed();
20
21
 
21
- const args = parseGitCommand(command);
22
+ const args = parseGitCommand(requireString(data, "command"));
22
23
 
23
24
  showBlock("Suggested Undo Command", command);
24
- console.log(chalk.yellow(explanation));
25
+ console.log(chalk.yellow(String(data?.explanation ?? "")));
25
26
 
26
27
  if (!(await confirm("Execute this undo command?"))) {
27
28
  return aborted("Execution aborted by user.");
package/src/lib/api.js CHANGED
@@ -2,20 +2,53 @@ import axios from "axios";
2
2
 
3
3
  export const DEFAULT_API_URL = "https://git-pilot-api.vercel.app";
4
4
 
5
- // The API rejects request bodies over 1 MB; keep the diff safely below that.
5
+ // The API rejects request bodies over 1 MB (bytes, after JSON encoding) and diffs over
6
+ // 800,000 characters.
6
7
  export const MAX_DIFF_CHARS = 800_000;
8
+ const MAX_BODY_BYTES = 1_000_000;
7
9
 
8
- export const apiBaseUrl = () =>
9
- (process.env.GIT_PILOT_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
10
+ /** True if `body` (containing `diff`) is small enough for the API to accept. */
11
+ export const fitsInRequest = (body, diff) =>
12
+ diff.length <= MAX_DIFF_CHARS && Buffer.byteLength(JSON.stringify(body)) <= MAX_BODY_BYTES;
13
+
14
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
15
+
16
+ export function apiBaseUrl() {
17
+ const raw = (process.env.GIT_PILOT_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
18
+
19
+ let url;
20
+ try {
21
+ url = new URL(raw);
22
+ } catch {
23
+ throw new Error(`GIT_PILOT_API_URL is not a valid URL: ${raw}`);
24
+ }
25
+ // Your staged diffs are sent to this server, so never over plain http except locally.
26
+ const plainHttpOk = url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname);
27
+ if (url.protocol !== "https:" && !plainHttpOk) {
28
+ throw new Error("GIT_PILOT_API_URL must use https (plain http is only allowed for localhost).");
29
+ }
30
+ return raw;
31
+ }
10
32
 
11
33
  /** POSTs `body` to `/api/<endpoint>` and returns the parsed response data. */
12
34
  export async function callApi(endpoint, body) {
13
35
  const { data } = await axios.post(`${apiBaseUrl()}/api/${endpoint}`, body, {
14
36
  timeout: 30_000,
37
+ maxRedirects: 0, // the API never redirects a POST; don't follow one to somewhere else
38
+ maxContentLength: 1_000_000,
15
39
  });
16
40
  return data;
17
41
  }
18
42
 
43
+ /** Returns `data[field]` if it is a non-empty string; otherwise throws a clear error. */
44
+ export function requireString(data, field) {
45
+ const value = data?.[field];
46
+ if (typeof value !== "string" || value === "") {
47
+ throw new Error(`Unexpected response from the API (missing "${field}").`);
48
+ }
49
+ return value;
50
+ }
51
+
19
52
  /** Human-readable message for an axios/network/other error. */
20
53
  export function describeError(error) {
21
54
  if (error.response) {
package/src/lib/exec.js CHANGED
@@ -3,6 +3,55 @@ import { execa } from "execa";
3
3
 
4
4
  export class UnsafeCommandError extends Error {}
5
5
 
6
+ // Mirrors the API's allowlist. The CLI re-checks it because the API (or a custom
7
+ // GIT_PILOT_API_URL) is an untrusted source of commands. This also rejects leading
8
+ // global options such as `git -c core.sshCommand=... fetch`.
9
+ const ALLOWED_SUBCOMMANDS = new Set([
10
+ "add",
11
+ "branch",
12
+ "checkout",
13
+ "cherry-pick",
14
+ "clone",
15
+ "commit",
16
+ "diff",
17
+ "fetch",
18
+ "log",
19
+ "merge",
20
+ "pull",
21
+ "push",
22
+ "rebase",
23
+ "reflog",
24
+ "reset",
25
+ "restore",
26
+ "revert",
27
+ "show",
28
+ "stash",
29
+ "status",
30
+ "switch",
31
+ "tag",
32
+ ]);
33
+
34
+ // Options that make git run another program or write to an arbitrary path.
35
+ const FORBIDDEN_OPTION = /^--(upload-pack|receive-pack|exec|exec-path|output)(=|$)/;
36
+
37
+ function assertSafeArgs(args) {
38
+ const [subcommand, ...rest] = args;
39
+ if (!ALLOWED_SUBCOMMANDS.has(subcommand)) {
40
+ throw new UnsafeCommandError(`"${subcommand}" is not an allowed git subcommand.`);
41
+ }
42
+
43
+ for (const arg of rest) {
44
+ if (arg === "--") break; // everything after is a path, not an option
45
+ if (
46
+ FORBIDDEN_OPTION.test(arg) ||
47
+ (subcommand === "rebase" && arg.startsWith("-x")) || // -x = --exec
48
+ (subcommand === "clone" && arg.startsWith("-u")) // -u = --upload-pack
49
+ ) {
50
+ throw new UnsafeCommandError(`The option "${arg}" is not allowed.`);
51
+ }
52
+ }
53
+ }
54
+
6
55
  /**
7
56
  * Turns a suggested command string into an argv array for `git`, or throws.
8
57
  * Only plain `git <args...>` is accepted: quoting is honored, but shell
@@ -27,7 +76,9 @@ export function parseGitCommand(command) {
27
76
  if (tokens[0] !== "git" || tokens.length < 2) {
28
77
  throw new UnsafeCommandError("Only git commands can be executed.");
29
78
  }
30
- return tokens.slice(1);
79
+ const args = tokens.slice(1);
80
+ assertSafeArgs(args);
81
+ return args;
31
82
  }
32
83
 
33
84
  /** Runs `git <args>` with the terminal attached. */