Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dd23155
feat(review): add Codex AI review agent with live logs, --local workt…
backnotprop Apr 5, 2026
9efc16a
style(review): UX polish pass — visual quality improvements across co…
backnotprop Apr 5, 2026
d558229
feat(review): PR comments panel — search, filter, collapse, navigatio…
backnotprop Apr 5, 2026
0c41712
feat(review): inline review threads with outdated/resolved state and …
backnotprop Apr 5, 2026
2725739
feat(review): Claude Code agent, cross-repo --local, render fixes
backnotprop Apr 6, 2026
81d9332
fix(review): decontaminate --local from diff pipeline, fix worktree s…
backnotprop Apr 6, 2026
b5e27d5
style(review): unify panel headers, responsive buttons, dockview polish
backnotprop Apr 6, 2026
abaab38
fix: type assertion for Bun stdin FileSink
backnotprop Apr 6, 2026
5ab5bb6
fix(review): XSS in sanitizeHtml, git flag injection, cross-repo ref …
backnotprop Apr 6, 2026
aea8de5
chore: upgrade @pierre/diffs from 1.1.0-beta.19 to ^1.1.12
backnotprop Apr 6, 2026
3f393ae
Merge branch 'main' into feat/hook-up-review-ai
backnotprop Apr 6, 2026
f4183bb
fix(review): remove shell provider, flag injection, Claude log format…
backnotprop Apr 6, 2026
6b510ac
fix: validate repo identifier to prevent flag injection in gh repo clone
backnotprop Apr 6, 2026
bb26579
feat(review): Claude-specific review model with severity, reasoning, …
backnotprop Apr 6, 2026
ee3b2b4
fix: use bg-amber-500 for nit severity dot (bg-warning may not be def…
backnotprop Apr 6, 2026
391ceaa
fix: security hardening, debug cleanup, deduplication, Pi mirroring, …
backnotprop Apr 6, 2026
5808b3c
fix: move toRelativePath import to top of claude-review.ts
backnotprop Apr 6, 2026
078f3d3
fix: same-repo detection compares host, platform-aware comment links,…
backnotprop Apr 6, 2026
2b35d5b
fix(review): show severity markers and reasoning in inline diff annot…
backnotprop Apr 6, 2026
6e07d0c
fix: prefix Claude findings text with [severity] tag
backnotprop Apr 6, 2026
ff11419
feat(pi): full agent review mirroring — stdin, stdout, live logs, res…
backnotprop Apr 6, 2026
4b00045
fix: remove duplicate isPRMode declaration in Pi serverReview.ts
backnotprop Apr 6, 2026
5059829
fix: include reasoning in all copy and feedback export paths
backnotprop Apr 6, 2026
dc05bc8
fix: six verified findings — navigation, dedup, cleanup, copy, diff m…
backnotprop Apr 6, 2026
4124841
perf: wrap ReviewSidebar in React.memo to prevent re-renders during l…
backnotprop Apr 6, 2026
cda3245
fix(security): remove find/ls/cat from Claude allowed tools, add glab…
backnotprop Apr 6, 2026
5bad2e9
fix: Pi addAnnotations, Pi stdout drain, cross-repo exit codes
backnotprop Apr 6, 2026
ee6ede3
fix: FETCH_HEAD ordering, Pi worktree-aware cwd, SEVERITY_ORDER hoisted
backnotprop Apr 6, 2026
2da62a4
fix: Bun/Pi parity — provider default, annotation error logging
backnotprop Apr 6, 2026
82dc068
docs: add AI Code Review Agents guide with full prompt transparency
backnotprop Apr 6, 2026
49347b8
docs: add provenance links for Claude and Codex review integrations
backnotprop Apr 6, 2026
0e8e0a8
fix: temp clone leak, j/k key conflict, thread header null line
backnotprop Apr 6, 2026
f8b5499
fix: hoist localPath for catch-block scope, validate baseSha format
backnotprop Apr 6, 2026
7003465
docs: rewrite AI Code Review guide for clarity and readability
backnotprop Apr 6, 2026
8ce8253
docs: rewrite transparency section with exact prompts and commands
backnotprop Apr 6, 2026
fa53662
docs: add mini TOC to transparency section
backnotprop Apr 6, 2026
6f44439
fix: stdout drain hang, Codex verdict override, Claude parse logging,…
backnotprop Apr 6, 2026
4a2a919
fix: type assertions for Bun ReadableStream async iteration
backnotprop Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
| `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) |
| `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all |
| `/api/agents/capabilities` | GET | Check available agent providers (claude, codex, shell) |
| `/api/agents/capabilities` | GET | Check available agent providers (claude, codex) |
| `/api/agents/jobs/stream` | GET | SSE stream for real-time agent job status updates |
| `/api/agents/jobs` | GET | Snapshot of agent jobs (polling fallback, `?since=N` for version gating) |
| `/api/agents/jobs` | POST | Launch an agent job (body: `{ provider, command, label }`) |
Expand Down
148 changes: 145 additions & 3 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,14 @@ import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { type DiffType, getVcsContext, runVcsDiff } from "@plannotator/server/vcs";
import { type DiffType, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs";
import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree";
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
import { resolveMarkdownFile, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { statSync } from "fs";
import { statSync, rmSync, realpathSync } from "fs";
import { parseRemoteUrl } from "@plannotator/shared/repo";
import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions";
import { openBrowser } from "@plannotator/server/browser";
import { detectProjectName } from "@plannotator/server/project";
Expand All @@ -85,6 +87,7 @@ import {
isTopLevelHelpInvocation,
} from "./cli";
import path from "path";
import { tmpdir } from "os";

// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
Expand Down Expand Up @@ -184,15 +187,26 @@ if (args[0] === "sessions") {
// CODE REVIEW MODE
// ============================================

// Parse local flags (strip before URL detection)
// --local is now the default for PR/MR reviews; --no-local opts out.
// --local kept for backwards compat (no-op).
const localIdx = args.indexOf("--local");
if (localIdx !== -1) args.splice(localIdx, 1);
const noLocalIdx = args.indexOf("--no-local");
if (noLocalIdx !== -1) args.splice(noLocalIdx, 1);

const urlArg = args[1];
const isPRMode = urlArg?.startsWith("http://") || urlArg?.startsWith("https://");
const useLocal = isPRMode && noLocalIdx === -1;

let rawPatch: string;
let gitRef: string;
let diffError: string | undefined;
let gitContext: Awaited<ReturnType<typeof getVcsContext>> | undefined;
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
let initialDiffType: DiffType | undefined;
let agentCwd: string | undefined;
let worktreeCleanup: (() => void | Promise<void>) | undefined;

if (isPRMode) {
// --- PR Review Mode ---
Expand Down Expand Up @@ -231,6 +245,132 @@ if (args[0] === "sessions") {
console.error(err instanceof Error ? err.message : "Failed to fetch PR");
process.exit(1);
}

// --local: create a local checkout with the PR head for full file access
if (useLocal && prMetadata) {
// Hoisted so catch block can clean up partially-created directories
let localPath: string | undefined;
try {
const repoDir = process.cwd();
const identifier = prMetadata.platform === "github"
? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}`
: `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`;
const suffix = Math.random().toString(36).slice(2, 8);
// Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/...
// but processes report /private/var/folders/... which breaks path stripping.
localPath = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
const fetchRefStr = prMetadata.platform === "github"
? `refs/pull/${prMetadata.number}/head`
: `refs/merge-requests/${prMetadata.iid}/head`;

// Validate inputs from platform API to prevent git flag/path injection
if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`);
if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`);

// Detect same-repo vs cross-repo (must match both owner/repo AND host)
let isSameRepo = false;
try {
const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]);
if (remoteResult.exitCode === 0) {
const remoteUrl = remoteResult.stdout.trim();
const currentRepo = parseRemoteUrl(remoteUrl);
const prRepo = prMetadata.platform === "github"
? `${prMetadata.owner}/${prMetadata.repo}`
: prMetadata.projectPath;
const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase();
// Extract host from remote URL to avoid cross-instance false positives (GHE)
const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1];
const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })();
const remoteHost = (sshHost || httpsHost || "").toLowerCase();
const prHost = prMetadata.host.toLowerCase();
isSameRepo = repoMatches && remoteHost === prHost;
}
} catch { /* not in a git repo — cross-repo path */ }

if (isSameRepo) {
// ── Same-repo: fast worktree path ──
console.error("Fetching PR branch and creating local worktree...");
// Fetch base branch so origin/<baseBranch> is current for agent diffs.
// Ensure baseSha is available (may fetch, which overwrites FETCH_HEAD).
// Both MUST happen before the PR head fetch since FETCH_HEAD is what
// createWorktree uses — the PR head fetch must be last.
await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir });
await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir });
// Fetch PR head LAST — sets FETCH_HEAD to the PR tip for createWorktree.
await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir });

await createWorktree(gitRuntime, {
ref: "FETCH_HEAD",
path: localPath,
detach: true,
cwd: repoDir,
});

worktreeCleanup = () => removeWorktree(gitRuntime, localPath, { force: true, cwd: repoDir });
process.once("exit", () => {
try { Bun.spawnSync(["git", "worktree", "remove", "--force", localPath]); } catch {}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explicit process.on("exit") cleanup runs git worktree remove without setting cwd to the source repo. If the process cwd changes during runtime, cleanup may fail. Prefer reusing worktreeCleanup (which already supplies cwd) or pass { cwd: repoDir } to the spawn call.

Suggested change
try { Bun.spawnSync(["git", "worktree", "remove", "--force", localPath]); } catch {}
try { Bun.spawnSync(["git", "worktree", "remove", "--force", localPath], { cwd: repoDir }); } catch {}

Copilot uses AI. Check for mistakes.
});
} else {
// ── Cross-repo: shallow clone + fetch PR head ──
const prRepo = prMetadata.platform === "github"
? `${prMetadata.owner}/${prMetadata.repo}`
: prMetadata.projectPath;
// Validate repo identifier to prevent flag injection via crafted URLs
if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`);
const cli = prMetadata.platform === "github" ? "gh" : "glab";
const host = prMetadata.host;
const hostnameArgs = (host === "github.com" || host === "gitlab.com") ? [] : ["--hostname", host];

// Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer)
console.error(`Cloning ${prRepo} (shallow)...`);
const cloneResult = Bun.spawnSync(
[cli, "repo", "clone", prRepo, localPath, ...hostnameArgs, "--", "--depth=1", "--no-checkout"],
{ stderr: "pipe" },
);
Comment on lines +326 to +329
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] gh repo clone passes unvalidated owner/repo before --, enabling flag injection via crafted PR URL

The prRepo value (owner/repo) comes from a URL regex that only excludes / — it allows any other characters, including --flag=value. The -- end-of-flags marker is placed after prRepo and localPath, so it does not protect against flag injection:

[cli, "repo", "clone", prRepo, localPath, ...hostnameArgs, "--", "--depth=1", "--no-checkout"]

A URL like https://github.com/--upload-pack=malicious/repo/pull/1 would produce owner = "--upload-pack=malicious", which gh interprets as a flag. The --upload-pack option causes gh/git to execute an arbitrary command on clone.

Fix: move -- before prRepo:

[cli, "repo", "clone", "--", prRepo, localPath, ...hostnameArgs, "--depth=1", "--no-checkout"]

Or validate owner and repo match ^[a-zA-Z0-9._-]+$ before use.

if (cloneResult.exitCode !== 0) {
throw new Error(`${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`);
}

// Step 2: Fetch only the PR head ref (targeted, much faster than full fetch)
console.error("Fetching PR branch...");
const fetchResult = Bun.spawnSync(
["git", "fetch", "--depth=50", "origin", fetchRefStr],
{ cwd: localPath, stderr: "pipe" },
);
if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`);

// Step 3: Checkout PR head (critical — if this fails, worktree is empty)
const checkoutResult = Bun.spawnSync(["git", "checkout", "FETCH_HEAD"], { cwd: localPath, stderr: "pipe" });
if (checkoutResult.exitCode !== 0) {
throw new Error(`git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`);
}

// Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work
const baseFetch = Bun.spawnSync(["git", "fetch", "--depth=50", "origin", prMetadata.baseSha], { cwd: localPath, stderr: "pipe" });
if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate");
Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" });
Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" });

worktreeCleanup = () => { try { rmSync(localPath, { recursive: true, force: true }); } catch {} };
process.once("exit", () => {
try { Bun.spawnSync(["rm", "-rf", localPath]); } catch {}
});
}

// --local only provides a sandbox path for agent processes.
// Do NOT set gitContext — that would contaminate the diff pipeline.
agentCwd = localPath;

console.error(`Local checkout ready at ${localPath}`);
} catch (err) {
console.error(`Warning: --local failed, falling back to remote diff`);
console.error(err instanceof Error ? err.message : String(err));
// Clean up partially-created directory (clone may have succeeded before fetch/checkout failed)
if (localPath) try { rmSync(localPath, { recursive: true, force: true }); } catch {}
agentCwd = undefined;
worktreeCleanup = undefined;
}
}
} else {
// --- Local Review Mode ---
gitContext = await getVcsContext();
Expand All @@ -249,12 +389,14 @@ if (args[0] === "sessions") {
gitRef,
error: diffError,
origin: detectedOrigin,
diffType: isPRMode ? undefined : (initialDiffType ?? "uncommitted"),
diffType: gitContext ? (initialDiffType ?? "uncommitted") : undefined,
gitContext,
prMetadata,
agentCwd,
sharingEnabled,
shareBaseUrl,
htmlContent: reviewHtmlContent,
onCleanup: worktreeCleanup,
onReady: async (url, isRemote, port) => {
handleReviewServerReady(url, isRemote, port);

Expand Down
Loading