pi-pr-review 1.8.0 → 1.9.0

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.8.0"
2
+ ".": "1.9.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0](https://github.com/10ego/pi-pr-review/compare/v1.8.0...v1.9.0) (2026-07-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * **review:** add constrained one-shot self-review ([#18](https://github.com/10ego/pi-pr-review/issues/18)) ([ab267c4](https://github.com/10ego/pi-pr-review/commit/ab267c40415731b2fbbea91c5b345153fa7b6132))
9
+
3
10
  ## [1.8.0](https://github.com/10ego/pi-pr-review/compare/v1.7.1...v1.8.0) (2026-07-14)
4
11
 
5
12
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ The default review prioritizes P0–P2 defects and allows up to three minor find
14
14
 
15
15
  ## Requirements
16
16
 
17
- - Pi `0.77.0` or newer.
17
+ - Pi `0.80.5` or newer (the first release with the terminal `agent_settled` lifecycle event).
18
18
  - [`gh`](https://cli.github.com/) installed and authenticated with `gh auth login`.
19
19
  - Pi running inside the repository that owns the PR.
20
20
 
@@ -158,6 +158,20 @@ Example:
158
158
 
159
159
  Tier subprocesses retry configured fallbacks only for retryable quota, rate-limit, or capacity failures. If a tier is unset, it uses the nearest configured tier and then Pi's default model.
160
160
 
161
+ ## One-shot self-review for top-level tasks
162
+
163
+ For an eligible long-running coding task started by direct interactive or RPC input, Pi exposes `self_review_subagent` near the end of the task. The tool takes no arguments. Its empty schema is closed with `additionalProperties: false`; the extension—not the caller—fixes the objective, heavy tier, P0–P2-only severity policy, no-minor-hygiene policy, and no-tools isolation.
164
+
165
+ The permit is bound to one top-level task generation, the Pi session instance, cwd, and canonical Git worktree. Dispatch is additionally bound to the tool-call ID from an assistant `message_end` containing exactly one tool call, `self_review_subagent`; mixed, multiple, or direct unbound dispatches are denied without consuming the reusable task permit. A bound permit is consumed atomically before delta capture, so concurrent or replayed calls are rejected and the tool is hidden immediately. It is never available during `/pr-review`. Low-level `agent_end` events do not revoke it because Pi may still retry, compact, or run queued continuations; unused authority is cleared only at terminal `agent_settled` (or earlier cancellation/session/input boundaries).
166
+
167
+ The child uses Pi RPC mode with a bounded ten-minute total runtime. Before startup, the host creates a mode-`0700` temporary `PI_CODING_AGENT_DIR`, copies and normalizes the trusted user settings there with retry and compaction disabled, and exposes only validated regular `auth.json`/`models.json` files through controlled symlinks. The same private directory—not the mutable worktree—is the child process cwd, and inherited runtime preload flags (`NODE_OPTIONS` and `BUN_OPTIONS`) are removed. RPC control acknowledgements can therefore persist only to temporary settings. The host attempts synchronous recursive removal after every supervised outcome; unsafe source configuration or cleanup failure fails the call closed. Environment-based authentication otherwise remains inherited, and no credentials are placed in arguments or prompts. The host waits for acknowledgements that automatic compaction and automatic retry are disabled before it submits the sole prompt, and it also aborts on timeout, bounded stdout/stderr overflow, any retry/compaction lifecycle event, or a second `agent_start`. There is no fallback, sharding, extension discovery, tools, publication, or verification behavior.
168
+
169
+ Self-review is deliberately fail-closed. At extension startup, the host resolves one canonical executable Git from that startup `PATH`; the same absolute executable is bound through clean-baseline capture, permit validation, and delta capture, so later `PATH` changes cannot select repository-controlled Git. The worktree must be clean when the top-level task starts and HEAD must remain unchanged. Baseline capture receives its abort signal immediately, so new input, cancellation, or session/tree navigation can stop even the initial Git inspection. At execution, the host builds a complete bounded diff of Git-visible tracked, staged, and non-ignored untracked changes relative to that starting HEAD. It rejects an empty delta, any changed or dirty submodule, more than 200 status records, more than 4 MiB of diff, a changed session/cwd/worktree/HEAD, or a status change during capture.
170
+
171
+ The child must return strict JSON containing only a `findings` array. Every finding must pass an exact host-owned schema: P0/P1/P2 severity and matching title/blocking state, concrete impact/trigger/evidence strings, normalized repo-relative changed-line coordinates, side, task/diff relationship flags, and bounded confidence. The host derives changed-line anchors from the captured unified diff and requires each claimed range to lie completely within one changed-line run in one hunk on the exact claimed path and side; binary and no-hunk paths have no valid anchors. Markdown, malformed JSON, extra or missing fields, P3/nits, inconsistent metadata, out-of-delta anchors, and unsafe paths are rejected rather than shown as review results. No findings is `{"findings":[]}`.
172
+
173
+ This is a practical Git-derived boundary, not a filesystem snapshot or sandbox. Ignored files are not included, and an external process that rewrites ordinary file contents without changing status shape could race capture. Requiring a clean start, rejecting submodule deltas, checking HEAD and status before/after capture, incrementally bounding all output, and failing closed avoids silently presenting a partial oversized delta. The RPC leader starts in a detached POSIX process group; on failure, abort, timeout, or retained descendants, the host sends group TERM then KILL, destroys inherited pipes, and stops waiting after a bounded drain deadline. A descendant that deliberately creates a different process group/session can escape those signals, although it cannot force the host to retain the supervised pipes indefinitely. Use a separate sandbox or snapshot system when stronger filesystem, process, or network isolation is required.
174
+
161
175
  ## Publish to GitHub
162
176
 
163
177
  Publishing is off by default.
@@ -237,6 +251,8 @@ The verdict is `request_changes` only when a validated P0 or P1 finding exists.
237
251
  - Every review tool also checks an in-memory, session- and cwd-bound loop lease before reading review context, running verification, or spawning a reviewer. Hiding the tools is not the only enforcement boundary.
238
252
  - Unrelated input, terminal completion, cancellation, session navigation, or tree navigation revokes the lease and aborts in-flight review work. Tools are suspended while a non-open PR waits for confirmation.
239
253
  - Reviewer subprocesses start with extension discovery disabled, so they cannot recursively invoke this package's agents or verification tool.
254
+ - `self_review_subagent` has separate one-shot authority for an eligible direct top-level task; it cannot authorize `review_subagent`, `review_subagents`, or `pr_review_verify`, and those permissive schemas remain exclusively behind `ReviewLoopCoordinator`.
255
+ - Self-review execute-time checks are authoritative: visibility alone never grants a permit, and consumption hides the tool before any asynchronous delta or child work.
240
256
  - Reviewers receive the captured diff and are instructed not to modify files.
241
257
  - The orchestrator does not check out, commit, or push PR code.
242
258
  - GitHub writes require `--comment`, an effective `autoPostReviews: true` setting, the model-free `/pr-review-publish` command, or a narrowly matched direct interactive/RPC publish request handled by the extension before an agent turn. `allowStalePublish` controls whether an invocation/config-authorized write may be stale; it does not independently authorize a write.
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { ReviewLoopCoordinator } from "../lib/pr-review-loop.ts";
3
+ import { SelfReviewPermitCoordinator } from "../lib/pr-self-review.ts";
3
4
  import registerPrReviewSubagents from "./pr-review-subagent.ts";
4
5
  import registerReviewFocus from "./pr-review-focus.ts";
5
6
  import registerReviewTable from "./review-table.ts";
@@ -7,7 +8,8 @@ import registerReviewTable from "./review-table.ts";
7
8
  /** Register the package behind one shared, session-scoped review-loop authority. */
8
9
  export default function registerPrReview(pi: ExtensionAPI) {
9
10
  const loopCoordinator = new ReviewLoopCoordinator(pi);
10
- registerPrReviewSubagents(pi, loopCoordinator);
11
+ const selfReviewCoordinator = new SelfReviewPermitCoordinator(pi, () => !!loopCoordinator.peek());
12
+ registerPrReviewSubagents(pi, loopCoordinator, selfReviewCoordinator);
11
13
  registerReviewFocus(pi, loopCoordinator);
12
- registerReviewTable(pi, loopCoordinator);
14
+ registerReviewTable(pi, loopCoordinator, selfReviewCoordinator);
13
15
  }
@@ -56,6 +56,13 @@ import {
56
56
  reviewLoopDeniedResult,
57
57
  type ReviewFocusPublisher,
58
58
  } from "../lib/pr-review-loop.ts";
59
+ import { runSelfReviewRpcSubprocess } from "../lib/pr-self-review-rpc.ts";
60
+ import {
61
+ buildSelfReviewDelta,
62
+ parseSelfReviewOutput,
63
+ SelfReviewPermitCoordinator,
64
+ selfReviewDeniedResult,
65
+ } from "../lib/pr-self-review.ts";
59
66
  import {
60
67
  normalizeReviewFocusJsonEvent,
61
68
  ReviewJsonLineDecoder,
@@ -705,6 +712,69 @@ async function runSubagentAttempt(
705
712
  }
706
713
  }
707
714
 
715
+ const SELF_REVIEW_SYSTEM_PROMPT = [
716
+ "You are an isolated self-review subagent invoked once by the host for a completed top-level coding task.",
717
+ "Review only the complete host-derived Git working-tree delta supplied below. Do not infer or request caller context.",
718
+ "Find only substantiated defects introduced by this delta in correctness, security, performance, state/lifecycle, concurrency, or integration behavior.",
719
+ "Report only P0, P1, or P2 findings. Do not report P3/nits, style, naming, documentation, tests-only hygiene, or subjective maintainability preferences, and never inflate severity.",
720
+ "You have no tools. Do not attempt to inspect paths, modify files, run verification, publish comments, or delegate work.",
721
+ "Return only strict JSON with this exact shape and no Markdown fence: {\"findings\":[{\"title\":\"[P2] Imperative summary\",\"severity\":\"P2\",\"blocking\":false,\"impact\":\"concrete user/system impact\",\"trigger\":\"exact input or environment\",\"evidence\":\"specific causal evidence visible in the delta\",\"path\":\"repo/relative/path.ts\",\"startLine\":1,\"endLine\":1,\"side\":\"RIGHT\",\"inDiff\":true,\"prRelated\":true,\"confidence\":0.9}]}",
722
+ "Every listed field is required and no additional fields are allowed. blocking is true only for P0/P1. Use exact changed-line coordinates and LEFT only for removed lines. Emit {\"findings\":[]} when no substantiated P0-P2 finding survives validation.",
723
+ ].join("\n");
724
+
725
+ async function runSelfReviewAttempt(
726
+ config: PrReviewConfig,
727
+ worktree: string,
728
+ delta: string,
729
+ signal: AbortSignal | undefined,
730
+ onText?: (text: string) => void,
731
+ ): Promise<{ result: RunResult; modelSpec?: string; elapsedMs: number }> {
732
+ let tmp: { dir: string; filePath: string } | undefined;
733
+ const startedAt = monotonicNow();
734
+ const modelSpec = config.tiers.heavy;
735
+ try {
736
+ const args = buildReviewBaseArgs();
737
+ args[args.indexOf("json")] = "rpc";
738
+ args.splice(args.indexOf("-p"), 1);
739
+ if (modelSpec) args.push("--model", modelSpec);
740
+ appendTierThinkingArgs(args, modelSpec, config.thinkingLevels.heavy);
741
+ args.push("--no-tools", "--no-approve");
742
+ tmp = await writeTempPrompt("heavy", SELF_REVIEW_SYSTEM_PROMPT);
743
+ args.push("--append-system-prompt", tmp.filePath);
744
+ const task = [
745
+ "Objective: Perform the host-defined one-shot major-only self-review of this complete task delta.",
746
+ "",
747
+ "--- Complete host-derived task delta ---",
748
+ delta,
749
+ ].join("\n");
750
+ const invocation = getPiInvocation(args);
751
+ const result = await runSelfReviewRpcSubprocess(
752
+ invocation.command,
753
+ invocation.args,
754
+ worktree,
755
+ task,
756
+ signal,
757
+ getAgentDir(),
758
+ );
759
+ if (result.text) onText?.(result.text);
760
+ return { result, modelSpec, elapsedMs: monotonicNow() - startedAt };
761
+ } catch (error) {
762
+ return {
763
+ result: { text: "", exitCode: 1, stderr: "", errorMessage: errMessage(error), toolElapsedMs: 0 },
764
+ modelSpec,
765
+ elapsedMs: monotonicNow() - startedAt,
766
+ };
767
+ } finally {
768
+ if (tmp) {
769
+ try {
770
+ fs.rmSync(tmp.dir, { recursive: true, force: true });
771
+ } catch {
772
+ /* ignore */
773
+ }
774
+ }
775
+ }
776
+ }
777
+
708
778
  async function runSubagentPass(
709
779
  config: PrReviewConfig,
710
780
  ctx: Pick<ExtensionContext, "cwd">,
@@ -862,6 +932,8 @@ function combineContexts(shared: string | undefined, specific: string | undefine
862
932
  // Extension
863
933
  // ---------------------------------------------------------------------------
864
934
 
935
+ const SelfReviewSubagentParams = Type.Object({}, { additionalProperties: false });
936
+
865
937
  const PrReviewVerifyParams = Type.Union([
866
938
  Type.Object(
867
939
  {
@@ -1006,11 +1078,97 @@ const ReviewSubagentsParams = Type.Object({
1006
1078
  export default function registerPrReviewSubagents(
1007
1079
  pi: ExtensionAPI,
1008
1080
  loopCoordinator = new ReviewLoopCoordinator(pi),
1081
+ selfReviewCoordinator = new SelfReviewPermitCoordinator(pi, () => !!loopCoordinator.peek()),
1009
1082
  ) {
1010
1083
  // Resolve security-sensitive executables only from the PATH trusted when this
1011
1084
  // extension starts, never from a later mutable process environment.
1012
1085
  const trustedStartupPath = process.env.PATH ?? "";
1013
1086
 
1087
+ pi.registerTool({
1088
+ name: "self_review_subagent",
1089
+ label: "Self Review Subagent",
1090
+ description: [
1091
+ "Perform the host-defined, one-shot P0-P2 self-review for the current eligible top-level user task.",
1092
+ "The host supplies the complete bounded Git-visible task delta, heavy-tier model, objective, isolation, and no-tools policy; this tool accepts no arguments.",
1093
+ ].join(" "),
1094
+ promptSnippet: "Run the one-shot host-bounded major-only self-review near the end of an eligible coding task",
1095
+ promptGuidelines: [
1096
+ "Call at most once, near the end of a long-running top-level coding task after implementation and focused tests.",
1097
+ "The permit is consumed before delta capture. A denied or failed call cannot be replayed, and the tool is unavailable during /pr-review.",
1098
+ ],
1099
+ parameters: SelfReviewSubagentParams,
1100
+
1101
+ async execute(toolCallId, _params, signal, onUpdate, ctx) {
1102
+ const permit = await selfReviewCoordinator.consume(toolCallId, ctx);
1103
+ if (!permit) return selfReviewDeniedResult();
1104
+ const executionSignal = combineAbortSignals(signal, permit.signal);
1105
+ try {
1106
+ if (executionSignal?.aborted) throw new Error("Self-review was aborted before delta capture.");
1107
+ const captured = await buildSelfReviewDelta({ ...permit, signal: executionSignal ?? permit.signal });
1108
+ if (executionSignal?.aborted) throw new Error("Self-review was aborted before the reviewer could start.");
1109
+ const config = loadConfig(ctx);
1110
+ const attempt = await runSelfReviewAttempt(
1111
+ config,
1112
+ permit.worktree,
1113
+ captured.delta,
1114
+ executionSignal,
1115
+ );
1116
+ const failed = attempt.result.exitCode !== 0 ||
1117
+ attempt.result.stopReason === "error" ||
1118
+ attempt.result.stopReason === "aborted";
1119
+ const details = {
1120
+ authorized: true,
1121
+ generation: permit.generation,
1122
+ tier: "heavy",
1123
+ model: attempt.result.model ?? attempt.modelSpec,
1124
+ exitCode: attempt.result.exitCode,
1125
+ status: failed ? "failed" : "completed",
1126
+ attempts: 1,
1127
+ fallbackUsed: false,
1128
+ toolPolicy: "none",
1129
+ majorOnly: true,
1130
+ minorHygiene: false,
1131
+ deltaBytes: captured.bytes,
1132
+ fileCount: captured.fileCount,
1133
+ elapsedMs: attempt.elapsedMs,
1134
+ };
1135
+ if (failed) {
1136
+ const error = attempt.result.errorMessage || attempt.result.stderr || attempt.result.text || "(no output)";
1137
+ return {
1138
+ content: [{ type: "text", text: `Self-review subagent failed: ${error}` }],
1139
+ isError: true,
1140
+ details,
1141
+ };
1142
+ }
1143
+ let report;
1144
+ try {
1145
+ report = parseSelfReviewOutput(attempt.result.text, captured.anchors);
1146
+ } catch (error) {
1147
+ return {
1148
+ content: [{ type: "text", text: `Self-review rejected malformed or out-of-policy output: ${errMessage(error)}` }],
1149
+ isError: true,
1150
+ details: { ...details, status: "failed", outputRejected: true },
1151
+ };
1152
+ }
1153
+ const text = JSON.stringify(report, null, 2);
1154
+ onUpdate?.({ content: [{ type: "text", text }] });
1155
+ return {
1156
+ content: [{ type: "text", text }],
1157
+ details: { ...details, findingCount: report.findings.length },
1158
+ };
1159
+ } catch (error) {
1160
+ return {
1161
+ content: [{ type: "text", text: `Self-review failed closed: ${errMessage(error)}` }],
1162
+ isError: true,
1163
+ details: { authorized: true, generation: permit.generation, attempts: 0 },
1164
+ };
1165
+ } finally {
1166
+ selfReviewCoordinator.finish(permit);
1167
+ }
1168
+ },
1169
+ });
1170
+ selfReviewCoordinator.hideTool();
1171
+
1014
1172
  pi.registerTool({
1015
1173
  name: "pr_review_verify",
1016
1174
  label: "PR Review Verify",
@@ -1361,6 +1519,7 @@ export default function registerPrReviewSubagents(
1361
1519
  handler: async (args, ctx) => {
1362
1520
  // Extension commands execute before input events, so revoke explicitly.
1363
1521
  loopCoordinator.clear();
1522
+ selfReviewCoordinator.clear();
1364
1523
  const raw = (args ?? "").trim();
1365
1524
  const parsed = parseConfigArgs(raw);
1366
1525
  if (parsed.errors.length) {
@@ -47,6 +47,7 @@ import {
47
47
  ReviewLoopCoordinator,
48
48
  type ReviewLoopInputSource,
49
49
  } from "../lib/pr-review-loop.ts";
50
+ import { SelfReviewPermitCoordinator } from "../lib/pr-self-review.ts";
50
51
  import {
51
52
  ReviewTelemetryTracker,
52
53
  type ReviewPerformanceTelemetry,
@@ -428,6 +429,7 @@ async function maybePublishReview(
428
429
  export default function registerReviewTable(
429
430
  pi: ExtensionAPI,
430
431
  loopCoordinator = new ReviewLoopCoordinator(pi),
432
+ selfReviewCoordinator = new SelfReviewPermitCoordinator(pi, () => !!loopCoordinator.peek()),
431
433
  ) {
432
434
  const completedReviews = new CompletedReviewCache();
433
435
  const sessionIdentity = (ctx: ExtensionContext): CompletedReviewSessionIdentity | undefined => {
@@ -510,6 +512,7 @@ export default function registerReviewTable(
510
512
  handler: async (args, ctx) => {
511
513
  // Extension commands execute before input hooks, so every invocation —
512
514
  // including malformed arguments — must revoke active review authority.
515
+ selfReviewCoordinator.clear();
513
516
  const active = loopCoordinator.peek();
514
517
  if (active) {
515
518
  loopCoordinator.clear();
@@ -549,6 +552,7 @@ export default function registerReviewTable(
549
552
 
550
553
  const revokeActiveLoop = () => {
551
554
  loopCoordinator.clear();
555
+ selfReviewCoordinator.clear();
552
556
  pendingCompletion = undefined;
553
557
  telemetryTracker.clear();
554
558
  };
@@ -563,8 +567,17 @@ export default function registerReviewTable(
563
567
  restoreCompletedReviews(ctx);
564
568
  });
565
569
 
570
+ pi.on("before_agent_start", async (_event, ctx) => {
571
+ await selfReviewCoordinator.beginTask(ctx);
572
+ });
573
+
574
+ pi.on("agent_settled", () => {
575
+ selfReviewCoordinator.clear();
576
+ });
577
+
566
578
  pi.on("session_tree", (event, ctx) => {
567
579
  loopCoordinator.clear();
580
+ selfReviewCoordinator.clear();
568
581
  pendingCompletion = undefined;
569
582
  restoreCompletedReviews(ctx);
570
583
  telemetryTracker.clear();
@@ -579,6 +592,9 @@ export default function registerReviewTable(
579
592
  });
580
593
 
581
594
  pi.on("input", async (event, ctx) => {
595
+ // Any new input revokes the prior top-level task generation before it can
596
+ // authorize a replay or a queued/steering continuation.
597
+ selfReviewCoordinator.clear();
582
598
  const source = event.source as ReviewLoopInputSource;
583
599
  const directPublish = parseDirectPublishRequest(event.text);
584
600
  if (
@@ -620,7 +636,10 @@ export default function registerReviewTable(
620
636
  persistTelemetry(parsed.matched && event.streamingBehavior === undefined ? "replaced" : "cleared");
621
637
  if (!parsed.matched) return;
622
638
  }
623
- if (!parsed.matched) return;
639
+ if (!parsed.matched) {
640
+ selfReviewCoordinator.noteTopLevelInput(source, event.streamingBehavior, ctx);
641
+ return;
642
+ }
624
643
  if (event.streamingBehavior !== undefined) {
625
644
  // Returning handled prevents queueing but does not stop the current parent
626
645
  // operation. Abort it so revoked review work cannot continue with built-ins.
@@ -696,7 +715,18 @@ export default function registerReviewTable(
696
715
  pi.on("message_end", async (event, ctx) => {
697
716
  if (event.message.role !== "assistant") return;
698
717
  const completion = classifyAssistantCompletion(event.message.stopReason, hasToolCall(event.message));
699
- if (completion === "continue_tools") return;
718
+ if (completion === "continue_tools") {
719
+ const toolCalls = Array.isArray(event.message.content)
720
+ ? event.message.content.filter((part) => part.type === "toolCall")
721
+ : [];
722
+ if (toolCalls.length === 1) {
723
+ const call = toolCalls[0] as { id?: unknown; name?: unknown };
724
+ if (call.name === "self_review_subagent" && typeof call.id === "string") {
725
+ selfReviewCoordinator.bindToolCall(call.id, ctx);
726
+ }
727
+ }
728
+ return;
729
+ }
700
730
  if (completion === "clear_invocation") {
701
731
  loopCoordinator.clear();
702
732
  persistTelemetry("cleared");
@@ -4,6 +4,7 @@ const RECURSIVE_REVIEW_TOOLS = new Set([
4
4
  "review_subagent",
5
5
  "review_subagents",
6
6
  "pr_review_verify",
7
+ "self_review_subagent",
7
8
  ]);
8
9
 
9
10
  /** Isolated review subprocesses receive all review context explicitly. */
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { monotonicNow, type MonotonicNow } from "./pr-review-telemetry.ts";
7
+ import { resolveTrustedExecutableFromStartupPath } from "./trusted-executable.ts";
7
8
 
8
9
  export const VERIFY_TIMEOUT_MIN_MS = 2_000;
9
10
  export const VERIFY_TIMEOUT_MAX_MS = 10 * 60_000;
@@ -617,33 +618,6 @@ function validateCanonicalExecutable(profile: VerificationBaselineProfile): stri
617
618
  return errors;
618
619
  }
619
620
 
620
- function canonicalExecutable(candidate: string, label: string): string {
621
- if (!path.isAbsolute(candidate)) throw new Error(`${label} executable path must be absolute`);
622
- const canonical = fs.realpathSync(candidate);
623
- const stat = fs.statSync(canonical);
624
- if (!stat.isFile()) throw new Error(`${label} executable must resolve to a regular file`);
625
- fs.accessSync(canonical, fs.constants.X_OK);
626
- return canonical;
627
- }
628
-
629
- function resolveTrustedExecutableFromStartupPath(
630
- name: "gh" | "git",
631
- injected: string | undefined,
632
- startupPath: string,
633
- ): string {
634
- if (injected) return canonicalExecutable(injected, name);
635
- for (const directory of startupPath.split(path.delimiter)) {
636
- if (!path.isAbsolute(directory)) continue;
637
- const candidate = path.join(directory, name);
638
- try {
639
- return canonicalExecutable(candidate, name);
640
- } catch {
641
- /* continue through the trusted extension startup PATH */
642
- }
643
- }
644
- throw new Error(`Unable to resolve an accessible ${name} executable from the trusted extension startup PATH.`);
645
- }
646
-
647
621
  function trustedStartupPath(options: VerifyOptions): string {
648
622
  return options.startupPath ?? (options.ghEnvironment ?? process.env).PATH ?? "";
649
623
  }