codeant-cli 0.5.12 → 0.5.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeant-cli",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,9 +1,11 @@
1
1
  import fs from 'fs/promises';
2
+ import { constants as fsConstants } from 'fs';
2
3
  import path from 'path';
3
4
  import { promisify } from 'util';
4
- import { exec } from 'child_process';
5
+ import { exec, execFile } from 'child_process';
5
6
 
6
7
  const execAsync = promisify(exec);
8
+ const execFileAsync = promisify(execFile);
7
9
 
8
10
  /**
9
11
  * Expand untracked paths from `git status --porcelain`.
@@ -468,18 +470,58 @@ class GitDiffHelper {
468
470
  return diffs;
469
471
  }
470
472
 
473
+ /**
474
+ * Read a working-tree file. Returns null if it resolves outside the repository (a symlink
475
+ * pointing elsewhere) or isn't a regular file, so those contents are never uploaded.
476
+ */
477
+ async _readRepoFile(relPath) {
478
+ const realRoot = await fs.realpath(this.gitRoot);
479
+ const isInside = p => {
480
+ const rel = path.relative(realRoot, p);
481
+ return !!rel && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel);
482
+ };
483
+ const realPath = await fs.realpath(path.resolve(this.gitRoot, relPath));
484
+ if (!isInside(realPath)) return null;
485
+
486
+ // Open first and then check what was actually opened, so a symlink swapped in after the
487
+ // check above can't redirect the read. O_NONBLOCK keeps a FIFO from blocking the open.
488
+ const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fsConstants;
489
+ const handle = await fs.open(realPath, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
490
+ try {
491
+ const opened = await handle.stat();
492
+ if (!opened.isFile()) return null;
493
+ // The opened file must still be the file at this path, and the path must still resolve
494
+ // inside the repo (a parent directory may have been swapped for a symlink)
495
+ const nowPath = await fs.realpath(realPath);
496
+ const now = await fs.stat(nowPath);
497
+ if (!isInside(nowPath) || now.dev !== opened.dev || now.ino !== opened.ino) return null;
498
+ return await handle.readFile('utf8');
499
+ } finally {
500
+ await handle.close();
501
+ }
502
+ }
503
+
471
504
  /**
472
505
  * Build a synthetic "new file" diff for an untracked file, which git diff can't see.
473
506
  * Returns null if the file can't be read.
474
507
  */
475
508
  async _buildUntrackedFileDiff(file) {
476
509
  try {
477
- const absolutePath = path.resolve(this.gitRoot, file);
478
- const content = await fs.readFile(absolutePath, 'utf8');
510
+ let content = await this._readRepoFile(file).catch(() => null);
511
+ let mode = '100644';
512
+ if (content === null) {
513
+ // A symlink we won't follow (it points outside the repo or nowhere): show the link
514
+ // itself, as git would once it's committed, using its target path and never the
515
+ // target's contents
516
+ const absolutePath = path.resolve(this.gitRoot, file);
517
+ if (!(await fs.lstat(absolutePath)).isSymbolicLink()) return null;
518
+ content = await fs.readlink(absolutePath);
519
+ mode = '120000';
520
+ }
479
521
  const lines = content.split('\n');
480
522
 
481
523
  let patchStr = `diff --git a/${file} b/${file}\n`;
482
- patchStr += `new file mode 100644\n`;
524
+ patchStr += `new file mode ${mode}\n`;
483
525
  patchStr += `index 0000000..${this._generateRandomHash()}\n`;
484
526
  patchStr += `--- /dev/null\n`;
485
527
  patchStr += `+++ b/${file}\n`;
@@ -549,8 +591,7 @@ class GitDiffHelper {
549
591
  let resolvedRef;
550
592
  for (const candidate of [branch, `origin/${branch}`]) {
551
593
  try {
552
- const { stdout } = await execAsync(`git rev-parse ${candidate}`, { cwd: this.gitRoot });
553
- resolvedRef = stdout.trim();
594
+ resolvedRef = await this._resolveCommitSha(candidate);
554
595
  break;
555
596
  } catch (err) {
556
597
  // try next
@@ -589,8 +630,7 @@ class GitDiffHelper {
589
630
  // Resolve the commit ref (supports HEAD~3, sha, tag, etc.)
590
631
  let resolvedCommit;
591
632
  try {
592
- const { stdout } = await execAsync(`git rev-parse ${commit}`, { cwd: this.gitRoot });
593
- resolvedCommit = stdout.trim();
633
+ resolvedCommit = await this._resolveCommitSha(commit);
594
634
  } catch (err) {
595
635
  throw new Error(`Could not resolve commit: ${commit}`);
596
636
  }
@@ -598,6 +638,21 @@ class GitDiffHelper {
598
638
  return this._getWorkingTreeDiffAgainst(resolvedCommit);
599
639
  }
600
640
 
641
+ /**
642
+ * Resolve a user-supplied ref (from the CLI or MCP) to a commit SHA without going through a
643
+ * shell, so later git commands only ever see the SHA. Throws if it doesn't name a commit.
644
+ */
645
+ async _resolveCommitSha(ref) {
646
+ // A leading "-" would be parsed as a git option (e.g. --output=<file>)
647
+ if (ref.startsWith('-')) throw new Error(`Invalid ref: ${ref}`);
648
+ const { stdout } = await execFileAsync(
649
+ 'git',
650
+ ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
651
+ { cwd: this.gitRoot }
652
+ );
653
+ return stdout.trim();
654
+ }
655
+
601
656
  /**
602
657
  * Diff a base commit against the working tree: commits since base, staged and unstaged
603
658
  * changes (including staged new files), plus untracked files not ignored by .gitignore.
@@ -1011,8 +1066,8 @@ class GitDiffHelper {
1011
1066
  } catch (err) {
1012
1067
  // Handle untracked files
1013
1068
  try {
1014
- const absoluteFilePath = path.resolve(this.gitRoot, filePath);
1015
- const headFileStr = await fs.readFile(absoluteFilePath, 'utf8');
1069
+ const headFileStr = await this._readRepoFile(filePath);
1070
+ if (headFileStr === null) throw new Error('Not a regular file inside the repository');
1016
1071
  const lines = headFileStr.split('\n');
1017
1072
 
1018
1073
  let patchStr = `diff --git a/${filePath} b/${filePath}\n`;
@@ -1069,9 +1124,9 @@ class GitDiffHelper {
1069
1124
  { cwd: this.gitRoot }
1070
1125
  ).then(r => r.stdout).catch(() => '');
1071
1126
  } else {
1072
- headFileStr = await fs.readFile(
1073
- path.join(this.gitRoot, filePath), 'utf8'
1074
- ).catch(() => '');
1127
+ headFileStr = await this._readRepoFile(filePath)
1128
+ .then(content => content ?? '')
1129
+ .catch(() => '');
1075
1130
  }
1076
1131
 
1077
1132
  // Get patch
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import fs from 'fs';
4
4
  import { minimatch } from 'minimatch';
5
5
 
6
- const MAX_REVIEW_FILES = 15;
6
+ const MAX_REVIEW_FILES = 40;
7
7
  const MAX_FILE_LINES = 5000;
8
8
  const MAX_FILE_SIZE_BYTES = 200 * 1024; // 200 KB
9
9