@expo/code-review-cli 0.6.0 → 0.8.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.
- package/README.md +151 -25
- package/build/cli.js +7 -0
- package/build/commands/ci.js +307 -36
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +170 -33
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +86 -11
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +99 -3
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +127 -10
- package/build/core/claude-code.js +691 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +282 -9
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +117 -15
- package/build/core/prompts.js +330 -5
- package/build/core/render.js +274 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +447 -39
- package/build/core/schema.js +219 -3
- package/build/core/scrub.js +63 -1
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +12 -0
- package/build/core/util.js +18 -0
- package/build/core/verify.js +18 -1
- package/build/reporters/github.js +544 -44
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +286 -7
- package/build/sources/local-git.js +6 -2
- package/build/sources/source.js +35 -0
- package/package.json +4 -3
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +71 -4
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +124 -1
- package/templates/workflow.yml +5 -0
package/build/core/schema.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0005#finding-identity-fingerprints
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { normalizeCode } from "./util.js";
|
|
@@ -21,7 +22,45 @@ export const FindingSchema = z.object({
|
|
|
21
22
|
* the finding is treated as hallucinated and dropped.
|
|
22
23
|
*/
|
|
23
24
|
evidence: z.string().optional(),
|
|
25
|
+
/**
|
|
26
|
+
* Set by the coordinator (and then ground-checked, see groundStackRequalification)
|
|
27
|
+
* when a later, stacked-on-top PR already addresses this absence-style finding:
|
|
28
|
+
* `prNumber` + the EXACT upstack manifest `file` relied on + a one-line `reason`.
|
|
29
|
+
* A requalified finding is never dropped — it renders in its own section, is
|
|
30
|
+
* counted, and is only excluded from the blocking decision. Never part of the
|
|
31
|
+
* fingerprint, so dismissal identity is stable across re-reviews.
|
|
32
|
+
*/
|
|
33
|
+
// @ref LLP 0010#requalification-schema-and-fingerprints [constrained-by] — annotate-only; excluded from fingerprintFinding so dismissal identity never lapses on requalification
|
|
34
|
+
requalifiedBy: z
|
|
35
|
+
.object({ prNumber: z.number().int(), file: z.string(), reason: z.string() })
|
|
36
|
+
.optional(),
|
|
37
|
+
/**
|
|
38
|
+
* Which reviewer agent produced this finding. Engine-populated, never the model:
|
|
39
|
+
* `ModelFindingSchema` omits it, so an `agent` in model JSON is dropped at the parse
|
|
40
|
+
* boundary and only the engine's fingerprint lookup can set it. Excluded from the
|
|
41
|
+
* fingerprint like `requalifiedBy`, so attribution appearing on a finding can never
|
|
42
|
+
* lapse an existing dismissal.
|
|
43
|
+
*/
|
|
44
|
+
// @ref LLP 0011#attribution-and-identity [constrained-by] — attribution is annotation-only; never part of fingerprintFinding
|
|
45
|
+
agent: z.string().optional(),
|
|
24
46
|
});
|
|
47
|
+
/**
|
|
48
|
+
* Title of the internal "overall PR risk" handoff finding. The cross-cutting
|
|
49
|
+
* reviewer (or the always-run security reviewer on a PR small enough to skip the
|
|
50
|
+
* cross-cutting pass) rides the ordinary finding channel to hand the coordinator
|
|
51
|
+
* a whole-PR risk assessment — see the "Overall PR risk handoff" section in
|
|
52
|
+
* `templates/shared.md`. It is prompt-level metadata, never a defect.
|
|
53
|
+
*/
|
|
54
|
+
// @ref LLP 0009#prompt-rules-for-adopters [implements] — the deterministic strip that makes the handoff independent of policy.includeSuggestions
|
|
55
|
+
export const OVERALL_PR_RISK_TITLE = "__overall_pr_risk__";
|
|
56
|
+
/**
|
|
57
|
+
* Is this the internal risk handoff rather than a real finding? Matched on the
|
|
58
|
+
* exact title the prompt specifies. Reported findings must never include it: it
|
|
59
|
+
* would surface to PR authors as a nonsense bullet, and it is not a defect.
|
|
60
|
+
*/
|
|
61
|
+
export function isOverallRiskHandoff(finding) {
|
|
62
|
+
return finding.title.trim() === OVERALL_PR_RISK_TITLE;
|
|
63
|
+
}
|
|
25
64
|
/** A verifier's verdict on whether a finding is real (adversarial refute pass). */
|
|
26
65
|
export const VerdictSchema = z.object({
|
|
27
66
|
verified: z.boolean(),
|
|
@@ -30,14 +69,36 @@ export const VerdictSchema = z.object({
|
|
|
30
69
|
export function parseVerdict(text) {
|
|
31
70
|
return VerdictSchema.parse(extractJsonObject(text));
|
|
32
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* A stack verifier's verdict on whether a later stacked PR's patch actually
|
|
74
|
+
* addresses an absence-style finding (v2 patch confirmation). Fails toward
|
|
75
|
+
* blocking: anything but a clear `addressed: true` strips the requalification.
|
|
76
|
+
*/
|
|
77
|
+
// @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — addressed !== true keeps the finding blocking
|
|
78
|
+
export const StackVerdictSchema = z.object({
|
|
79
|
+
addressed: z.boolean(),
|
|
80
|
+
reason: z.string().default(""),
|
|
81
|
+
});
|
|
82
|
+
export function parseStackVerdict(text) {
|
|
83
|
+
return StackVerdictSchema.parse(extractJsonObject(text));
|
|
84
|
+
}
|
|
85
|
+
// @ref LLP 0011#attribution-and-identity [implements] — `agent` is engine-populated, so the model-facing schema drops it at the parse boundary instead of trusting call sites to strip it
|
|
86
|
+
/**
|
|
87
|
+
* The finding shape a MODEL may emit: `FindingSchema` minus the engine-only `agent`.
|
|
88
|
+
* Both model outputs parse through this, so an `agent` a reviewer pass or the
|
|
89
|
+
* coordinator invented is dropped where model JSON becomes typed data — the engine's own
|
|
90
|
+
* fingerprint lookup is then the only thing that can set it. The transform re-widens the
|
|
91
|
+
* result to `Finding` so downstream code (which does set `agent`) needs no change.
|
|
92
|
+
*/
|
|
93
|
+
const ModelFindingSchema = FindingSchema.omit({ agent: true }).transform((finding) => finding);
|
|
33
94
|
/** Shape each sub-reviewer must emit. */
|
|
34
95
|
export const ReviewerOutputSchema = z.object({
|
|
35
|
-
findings: z.array(
|
|
96
|
+
findings: z.array(ModelFindingSchema).default([]),
|
|
36
97
|
});
|
|
37
98
|
/** Mode-agnostic coordinator result; each Reporter decides how to render it. */
|
|
38
99
|
export const CoordinatorOutputSchema = z.object({
|
|
39
100
|
decision: z.enum(DECISIONS),
|
|
40
|
-
findings: z.array(
|
|
101
|
+
findings: z.array(ModelFindingSchema).default([]),
|
|
41
102
|
summary: z.string(),
|
|
42
103
|
/**
|
|
43
104
|
* Human-readable notes about reduced coverage (e.g. a review pass that hit its
|
|
@@ -55,9 +116,158 @@ export const CoordinatorOutputSchema = z.object({
|
|
|
55
116
|
*/
|
|
56
117
|
couldNotComplete: z.boolean().optional(),
|
|
57
118
|
});
|
|
119
|
+
/** How an author's reply to a finding held up against the source. */
|
|
120
|
+
export const FEEDBACK_VERDICTS = ["accepted", "refuted", "unclear"];
|
|
121
|
+
/** The kinds of pushback authors actually write, as a closed set. */
|
|
122
|
+
export const FEEDBACK_REASONS = [
|
|
123
|
+
"pre-existing", // the PR only continues a pattern already in the repo
|
|
124
|
+
"deliberate-scope", // a bounded, intentional limitation of new code
|
|
125
|
+
"fixed", // the author says they addressed it
|
|
126
|
+
"disagree", // the author disputes the analysis itself
|
|
127
|
+
"other",
|
|
128
|
+
];
|
|
129
|
+
// @ref LLP 0011#never-echo-reply-text [constrained-by] — no free-text field: reply prose never reaches the comment body, so it can never carry a forged state marker
|
|
130
|
+
/**
|
|
131
|
+
* One author reply matched to one finding, keyed by fingerprint. Everything here
|
|
132
|
+
* is either engine-derived or enum-valued — deliberately NO free-text field. The
|
|
133
|
+
* reply's own prose is never stored and never rendered; adding a field for it
|
|
134
|
+
* would put attacker-written text back into the comment body.
|
|
135
|
+
*/
|
|
136
|
+
export const FeedbackRecordSchema = z.object({
|
|
137
|
+
fp: z.string(),
|
|
138
|
+
by: z.string(),
|
|
139
|
+
commentId: z.number().int(),
|
|
140
|
+
url: z.string().optional(),
|
|
141
|
+
maintainer: z.boolean().default(false),
|
|
142
|
+
/**
|
|
143
|
+
* True when the replying login is the PR author. Re-derived from the live comment
|
|
144
|
+
* every run (never trusted from stored state), so it is unspoofable like `maintainer`.
|
|
145
|
+
* Only a maintainer OR the PR author may clear a finding via a reply — an untrusted
|
|
146
|
+
* third-party commenter is annotated but can never be counted as an adjudicatable
|
|
147
|
+
* rebuttal (feedbackApplied gates the adjudicated path on this).
|
|
148
|
+
*/
|
|
149
|
+
// @ref LLP 0011#hard-floors-in-code [constrained-by] — the adjudicated clear path is for the PR author only; a third-party commenter's rebuttal never clears
|
|
150
|
+
author: z.boolean().optional(),
|
|
151
|
+
/**
|
|
152
|
+
* True when the reply cites this finding's `id:<fp>` token in the replier's OWN words
|
|
153
|
+
* (outside every blockquote). Re-derived from the live comment every run by
|
|
154
|
+
* `matchReplies`, never trusted from stored state, exactly like `maintainer`/`author`.
|
|
155
|
+
* A quote-only match still ANNOTATES the finding; only a cited id may CLEAR it, on
|
|
156
|
+
* both clear paths. Absent ⇒ false ⇒ clears nothing, so a record written before this
|
|
157
|
+
* field parses fine and fails closed until its reply is matched again.
|
|
158
|
+
*/
|
|
159
|
+
// @ref LLP 0011#a-quote-annotates-an-id-clears [constrained-by] — a quoted line can be text the untrusted PR author planted for a maintainer to quote-reply, so a quote may annotate but never clear
|
|
160
|
+
citedId: z.boolean().optional(),
|
|
161
|
+
verdict: z.enum(FEEDBACK_VERDICTS).optional(),
|
|
162
|
+
reason: z.enum(FEEDBACK_REASONS).optional(),
|
|
163
|
+
/**
|
|
164
|
+
* The reviewed head commit the verdict above was judged against (the PR head OID of
|
|
165
|
+
* the run whose adjudicator answered). A verdict is a statement about SOURCE, and a
|
|
166
|
+
* fingerprint deliberately excludes the line number, so a finding keeps its identity
|
|
167
|
+
* while the code that justified the rebuttal is edited away. Binding the verdict to
|
|
168
|
+
* the revision it judged is what lets mergeFeedback drop it once the head moves.
|
|
169
|
+
* Absent ⇒ unknown source (a record from before this field, or a run with no
|
|
170
|
+
* resolvable head OID): the verdict never carries, so a missing SHA can never pin a
|
|
171
|
+
* decision forever.
|
|
172
|
+
*/
|
|
173
|
+
// @ref LLP 0011#suppression-is-never-silent [constrained-by] — a carried verdict must be re-judged when the source it judged changes; unknown source fails safe to re-judging
|
|
174
|
+
sourceSha: z.string().optional(),
|
|
175
|
+
/** True when this reply actually removed the finding from the blocking set. */
|
|
176
|
+
applied: z.boolean().default(false),
|
|
177
|
+
/**
|
|
178
|
+
* Set when a human ran `/undismiss <id>` on a finding a reply had cleared: the
|
|
179
|
+
* finding returns to the active list and no live reply may re-apply it, so a later
|
|
180
|
+
* re-review recomputing `applied` from the still-present reply keeps it un-cleared.
|
|
181
|
+
*
|
|
182
|
+
* DERIVED, never the storage: the pin itself lives in the comment state's own `pins`
|
|
183
|
+
* set (see FeedbackPinSchema), which survives a run where no reply matches the
|
|
184
|
+
* finding at all. This flag is stamped back onto the record by `applyPins` on every
|
|
185
|
+
* render so `feedbackApplied` stays a pure record-local decision — and so a comment
|
|
186
|
+
* written by this version is still read correctly by an older one.
|
|
187
|
+
*/
|
|
188
|
+
// @ref LLP 0011#suppression-is-never-silent [constrained-by] — /undismiss must actually restore a reply-cleared finding, and the untrusted PR author must not be able to lift that restore by removing or replacing their reply
|
|
189
|
+
unclearedByHuman: z.boolean().optional(),
|
|
190
|
+
});
|
|
191
|
+
// @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — the pin is state about a FINDING, stored outside the reply record so a vanishing reply can never drop it
|
|
192
|
+
/**
|
|
193
|
+
* One maintainer `/undismiss` pin: the finding they restored, plus the reply comment
|
|
194
|
+
* the pin was applied against (absent when the finding had no reply record). The
|
|
195
|
+
* comment id is what makes "a maintainer's own NEWER reply lifts the pin" decidable
|
|
196
|
+
* without the pin having to live on a reply record: only a maintainer reply posted
|
|
197
|
+
* AFTER the pin (a strictly greater comment id — GitHub issue comment ids increase)
|
|
198
|
+
* releases it.
|
|
199
|
+
*/
|
|
200
|
+
export const FeedbackPinSchema = z.object({
|
|
201
|
+
fp: z.string(),
|
|
202
|
+
commentId: z.number().int().optional(),
|
|
203
|
+
});
|
|
204
|
+
/**
|
|
205
|
+
* The pin set to work with: the state's own `pins` plus any record-level
|
|
206
|
+
* `unclearedByHuman` flag. The second half is the migration for a comment written
|
|
207
|
+
* before `pins` existed — its pins live only on the records, and dropping them would
|
|
208
|
+
* silently lift a maintainer's restore on the first render by this version. Idempotent.
|
|
209
|
+
*/
|
|
210
|
+
export function collectPins(pins, records) {
|
|
211
|
+
const byFp = new Map();
|
|
212
|
+
for (const pin of pins ?? []) {
|
|
213
|
+
byFp.set(pin.fp, pin);
|
|
214
|
+
}
|
|
215
|
+
for (const record of records) {
|
|
216
|
+
if (record.unclearedByHuman === true && !byFp.has(record.fp)) {
|
|
217
|
+
byFp.set(record.fp, { fp: record.fp, commentId: record.commentId });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return [...byFp.values()];
|
|
221
|
+
}
|
|
222
|
+
// @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — the pin set is the single source of truth; the record flag is stamped from it, never the other way round
|
|
223
|
+
/**
|
|
224
|
+
* Carry the pin set across one render and stamp the records it covers. A pinned
|
|
225
|
+
* record can never be `applied` (the finding stays in the active list), and a record
|
|
226
|
+
* the set does NOT pin loses any stale flag — the set decides, so a flag left on a
|
|
227
|
+
* record could never resurrect a lifted pin.
|
|
228
|
+
*
|
|
229
|
+
* The only lift here is a maintainer's own newer reply: a reply record for the pinned
|
|
230
|
+
* finding, from a maintainer, posted after the pin. Everything else (a newer reply from
|
|
231
|
+
* the untrusted PR author, an edited or deleted reply, a re-review that matched no
|
|
232
|
+
* reply at all) leaves the pin exactly where it is. The other lift — a maintainer's
|
|
233
|
+
* `/dismiss` on that finding — happens in applyDismissalToState, the same trusted hand
|
|
234
|
+
* deciding the opposite way.
|
|
235
|
+
*/
|
|
236
|
+
export function applyPins(records, pins) {
|
|
237
|
+
const kept = pins.filter((pin) =>
|
|
238
|
+
// A pin with no recorded commentId is never lifted by a reply: "unknown" must not
|
|
239
|
+
// read as "older than every comment", which would let any maintainer reply lift it.
|
|
240
|
+
pin.commentId === undefined ||
|
|
241
|
+
!records.some((record) => record.fp === pin.fp && record.maintainer === true && record.commentId > pin.commentId));
|
|
242
|
+
const pinnedFps = new Set(kept.map((pin) => pin.fp));
|
|
243
|
+
const stamped = records.map((record) => {
|
|
244
|
+
if (pinnedFps.has(record.fp)) {
|
|
245
|
+
return { ...record, unclearedByHuman: true, applied: false };
|
|
246
|
+
}
|
|
247
|
+
if (record.unclearedByHuman === undefined) {
|
|
248
|
+
return record;
|
|
249
|
+
}
|
|
250
|
+
const { unclearedByHuman: _lifted, ...rest } = record;
|
|
251
|
+
return rest;
|
|
252
|
+
});
|
|
253
|
+
return { records: stamped, pins: kept };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The adjudicator's verdict on one rebuttal, re-derived from the source. Both
|
|
257
|
+
* fields are enum-constrained: the judgment is a classification, never prose the
|
|
258
|
+
* model could smuggle instructions (or a state marker) through.
|
|
259
|
+
*/
|
|
260
|
+
export const AdjudicationSchema = z.object({
|
|
261
|
+
verdict: z.enum(FEEDBACK_VERDICTS),
|
|
262
|
+
reason: z.enum(FEEDBACK_REASONS).default("other"),
|
|
263
|
+
});
|
|
264
|
+
export function parseAdjudication(text) {
|
|
265
|
+
return AdjudicationSchema.parse(extractJsonObject(text));
|
|
266
|
+
}
|
|
58
267
|
/** Minimum normalized evidence length to key a fingerprint on the code (below
|
|
59
268
|
* this we fall back to the title). */
|
|
60
269
|
const MIN_FP_EVIDENCE_LEN = 12;
|
|
270
|
+
// @ref LLP 0005#finding-identity-fingerprints [implements] — keys on evidence (v2) not the LLM-written title; excludes line number
|
|
61
271
|
/**
|
|
62
272
|
* Stable identifier for a finding — dedupes across re-reviews and is the key for
|
|
63
273
|
* dismissals. Excludes the line number (which shifts as a PR grows). Keys on the
|
|
@@ -73,6 +283,7 @@ export function fingerprintFinding(finding) {
|
|
|
73
283
|
const normalized = ["v2", finding.file, finding.category, key].join("|");
|
|
74
284
|
return createHash("sha1").update(normalized).digest("hex").slice(0, 12);
|
|
75
285
|
}
|
|
286
|
+
// @ref LLP 0005#finding-identity-fingerprints [implements] — default scope passes null so pre-routing dismissals still resolve (risk 9)
|
|
76
287
|
/**
|
|
77
288
|
* Namespace a finding's fingerprint by scope so cross-scope dismissals never
|
|
78
289
|
* collide. The DEFAULT scope (config '.') passes `null` and keeps the plain
|
|
@@ -112,7 +323,12 @@ export function extractJsonObject(text) {
|
|
|
112
323
|
lastError = error;
|
|
113
324
|
}
|
|
114
325
|
}
|
|
115
|
-
throw new Error(
|
|
326
|
+
throw new Error(lastError === undefined
|
|
327
|
+
? // No candidate was even tried: the response held no {...} block at all —
|
|
328
|
+
// empty output, or prose/pseudo-tool-call text with no JSON in it.
|
|
329
|
+
`Could not extract JSON from model response: no JSON object found in ` +
|
|
330
|
+
`${text.trim() === "" ? "an EMPTY response" : `a ${text.length}-char response with no {...}`}`
|
|
331
|
+
: `Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
116
332
|
}
|
|
117
333
|
/** The router's choice of which agent ids to run. */
|
|
118
334
|
export const RouteOutputSchema = z.object({
|
package/build/core/scrub.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
// @ref LLP 0001#read-root-scrubbing [implements] — strips ambient config + escaping symlinks before the model runtime roots here
|
|
2
|
+
import { readdir, realpath, rm } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
/**
|
|
4
5
|
* Ambient runtime configuration the OpenCode server (and the Claude-compatible
|
|
@@ -29,6 +30,7 @@ export const AMBIENT_RUNTIME_CONFIG_NAMES = new Set([
|
|
|
29
30
|
".cursor",
|
|
30
31
|
".cursorrules",
|
|
31
32
|
]);
|
|
33
|
+
// @ref LLP 0001#read-root-scrubbing [constrained-by] — .git skipped here for a different reason than node_modules is skipped below
|
|
32
34
|
/** Names never descended into (and never scrubbed as a unit — `.git` is the worktree link). */
|
|
33
35
|
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
34
36
|
/** Whether a directory entry is ambient runtime config that must not reach the model runtime. */
|
|
@@ -41,6 +43,7 @@ export function isAmbientRuntimeConfig(name) {
|
|
|
41
43
|
* extracted archive) — never on the user's checkout. Returns the repo-relative
|
|
42
44
|
* paths removed so callers can log them.
|
|
43
45
|
*/
|
|
46
|
+
// @ref LLP 0001#read-root-scrubbing [implements] — closes the fork-reachable code-execution path (601b19a)
|
|
44
47
|
export async function scrubAmbientRuntimeConfig(root) {
|
|
45
48
|
const removed = [];
|
|
46
49
|
const walk = async (dir) => {
|
|
@@ -60,3 +63,62 @@ export async function scrubAmbientRuntimeConfig(root) {
|
|
|
60
63
|
await walk(root);
|
|
61
64
|
return removed.sort();
|
|
62
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Remove symlinks whose fully resolved target lies outside the materialized tree.
|
|
68
|
+
*
|
|
69
|
+
* The model runtime's read tools are path-scoped by the LITERAL path argument
|
|
70
|
+
* (buildClaudeArgs' permission rules, and equally OpenCode's project-root
|
|
71
|
+
* containment), but the fs layer underneath follows symlinks — so a PR-committed
|
|
72
|
+
* link (`docs/notes.md -> ~/.claude/.credentials.json`) passes the in-tree check
|
|
73
|
+
* and reads the out-of-tree target. Git can only materialize regular files,
|
|
74
|
+
* directories, and symlinks, so stripping escaping symlinks here closes the whole
|
|
75
|
+
* class.
|
|
76
|
+
*
|
|
77
|
+
* Fail closed: a link whose target cannot be resolved (broken, or a chain that
|
|
78
|
+
* leaves the tree at any hop) is removed too — the target could come into
|
|
79
|
+
* existence later, and a broken link has no legitimate review value. In-tree
|
|
80
|
+
* links survive (realpath resolves chains, so an in-tree alias of an in-tree
|
|
81
|
+
* file is provably contained). Unlike the config scrub, this walk descends into
|
|
82
|
+
* node_modules (a committed one is attacker content); `.git` stays skipped — in
|
|
83
|
+
* a worktree it is an ECR-created gitdir link, not PR content.
|
|
84
|
+
*
|
|
85
|
+
* Must only ever run on a tree ECR created and will delete. Returns the
|
|
86
|
+
* repo-relative paths removed so callers can log them.
|
|
87
|
+
*/
|
|
88
|
+
// @ref LLP 0001#read-root-scrubbing [implements] — out-of-tree/broken/cyclic symlinks removed fail-closed
|
|
89
|
+
export async function removeEscapingSymlinks(root) {
|
|
90
|
+
// realpath the boundary itself: tmpdir-based roots are often behind symlinks
|
|
91
|
+
// (macOS /var -> /private/var), and containment must compare resolved paths.
|
|
92
|
+
const boundary = await realpath(root);
|
|
93
|
+
const removed = [];
|
|
94
|
+
const walk = async (dir) => {
|
|
95
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const full = path.join(dir, entry.name);
|
|
98
|
+
if (entry.isSymbolicLink()) {
|
|
99
|
+
let contained = false;
|
|
100
|
+
try {
|
|
101
|
+
const target = await realpath(full);
|
|
102
|
+
contained = target === boundary || target.startsWith(boundary + path.sep);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Unresolvable link: leave `contained` false (fail closed).
|
|
106
|
+
}
|
|
107
|
+
if (!contained) {
|
|
108
|
+
await rm(full, { force: true });
|
|
109
|
+
// Relative to the RESOLVED boundary — the walk runs there, and the
|
|
110
|
+
// caller's `root` may itself sit behind a symlink (macOS /var).
|
|
111
|
+
removed.push(path.relative(boundary, full));
|
|
112
|
+
}
|
|
113
|
+
// In-tree directory links are kept but never descended: their contents
|
|
114
|
+
// are walked once via the real path, and descending would loop on cycles.
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (entry.isDirectory() && entry.name !== ".git") {
|
|
118
|
+
await walk(full);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
await walk(boundary);
|
|
123
|
+
return removed.sort();
|
|
124
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @ref LLP 0010#patch-level-confirmation-v2 [implements] — path membership is not semantic proof; read the addressing patch, fail toward keeping the finding blocking
|
|
2
|
+
import { addTokenUsage, promptAndParse, STACK_VERIFIER_AGENT } from "./opencode.js";
|
|
3
|
+
import { buildStackVerifierSystem, buildStackVerifierTask } from "./prompts.js";
|
|
4
|
+
import { fingerprintFinding, parseStackVerdict } from "./schema.js";
|
|
5
|
+
import { manifestKey } from "./stack.js";
|
|
6
|
+
import { errorMessage } from "./util.js";
|
|
7
|
+
// Confirmation runs after coordination (a serial tail step), in parallel over unique
|
|
8
|
+
// candidates. A confirmation that runs long is stripped (fail toward blocking), so this
|
|
9
|
+
// timeout is a hard "give up and keep the finding" bound, not a retry trigger.
|
|
10
|
+
const STACK_CONFIRM_TIMEOUT_MS = 3 * 60 * 1000;
|
|
11
|
+
// @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — one verdict PER FINDING (only the patch fetch is shared); overflow past maxConfirmations is STRIPPED, not skipped; addressed!==true / error / timeout all STRIP
|
|
12
|
+
/**
|
|
13
|
+
* Confirm every surviving requalification against the addressing PR's real patch and
|
|
14
|
+
* strip any that is not clearly addressed. Pure orchestration over an injected
|
|
15
|
+
* `confirmOne` so the per-finding / overflow / fail-open logic is unit-testable
|
|
16
|
+
* without gh or a live model:
|
|
17
|
+
* - every requalified finding gets its OWN verdict — the verdict question is
|
|
18
|
+
* finding-specific ("does this patch supply what THIS finding says is missing"),
|
|
19
|
+
* so a shared verdict would let one confirmed finding clear an unrelated one
|
|
20
|
+
* citing the same `(prNumber, file)`. Only the patch FETCH is shared (memoized in
|
|
21
|
+
* `patchConfirmer`); identical findings (same fingerprint + citation) collapse;
|
|
22
|
+
* - only the first `maxConfirmations` candidates are confirmed — any beyond that
|
|
23
|
+
* cap have their requalification STRIPPED (fail toward blocking), never silently
|
|
24
|
+
* kept;
|
|
25
|
+
* - a candidate is kept ONLY on `addressed === true`; `false`, any thrown error, or a
|
|
26
|
+
* timeout strips it.
|
|
27
|
+
* A stripped requalification leaves the finding fully intact and blocking.
|
|
28
|
+
*/
|
|
29
|
+
export async function confirmStackRequalifications(findings, maxConfirmations, confirmOne, debug = () => { }) {
|
|
30
|
+
// One candidate per requalified finding, in first-seen order. The key pairs the
|
|
31
|
+
// citation with the finding's fingerprint so only true duplicates share a verdict.
|
|
32
|
+
const candidateKey = (finding, prNumber, file) => `${manifestKey(prNumber, file)}|${fingerprintFinding(finding)}`;
|
|
33
|
+
const candidates = new Map();
|
|
34
|
+
for (const finding of findings) {
|
|
35
|
+
const requalified = finding.requalifiedBy;
|
|
36
|
+
if (!requalified) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const key = candidateKey(finding, requalified.prNumber, requalified.file);
|
|
40
|
+
if (!candidates.has(key)) {
|
|
41
|
+
candidates.set(key, { prNumber: requalified.prNumber, file: requalified.file, finding });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const keys = [...candidates.keys()];
|
|
45
|
+
const withinCap = new Set(keys.slice(0, maxConfirmations));
|
|
46
|
+
const overflow = keys.slice(maxConfirmations);
|
|
47
|
+
// Per-candidate strip reason, for the run-log audit trail the caller persists.
|
|
48
|
+
const stripReasons = new Map();
|
|
49
|
+
for (const key of overflow) {
|
|
50
|
+
stripReasons.set(key, `over maxConfirmations=${maxConfirmations} — stripped unconfirmed`);
|
|
51
|
+
}
|
|
52
|
+
if (overflow.length > 0) {
|
|
53
|
+
debug(`Stack: ${overflow.length} requalification(s) over maxConfirmations=${maxConfirmations} — stripped unconfirmed.`);
|
|
54
|
+
}
|
|
55
|
+
// Confirm the within-cap candidates in parallel; each call is independently guarded.
|
|
56
|
+
const addressed = new Set();
|
|
57
|
+
let cost = 0;
|
|
58
|
+
let model;
|
|
59
|
+
const tokens = {};
|
|
60
|
+
await Promise.all([...withinCap].map(async (key) => {
|
|
61
|
+
const candidate = candidates.get(key);
|
|
62
|
+
try {
|
|
63
|
+
const result = await confirmOne(candidate);
|
|
64
|
+
cost += result.cost;
|
|
65
|
+
addTokenUsage(tokens, result.tokens);
|
|
66
|
+
model = result.model ?? model;
|
|
67
|
+
if (result.addressed) {
|
|
68
|
+
addressed.add(key);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
stripReasons.set(key, "patch does not address it");
|
|
72
|
+
debug(`Stack: stripped requalification on "${candidate.finding.file}" (patch does not address it).`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// Fail toward blocking: a fetch/verify error keeps the finding blocking.
|
|
77
|
+
stripReasons.set(key, `confirmation failed: ${errorMessage(error)}`);
|
|
78
|
+
debug(`Stack: stripped requalification on "${candidate.finding.file}" (confirmation failed: ${errorMessage(error)}).`);
|
|
79
|
+
}
|
|
80
|
+
}));
|
|
81
|
+
let stripped = 0;
|
|
82
|
+
const strippedFindings = [];
|
|
83
|
+
const out = findings.map((finding) => {
|
|
84
|
+
const requalified = finding.requalifiedBy;
|
|
85
|
+
if (!requalified) {
|
|
86
|
+
return finding;
|
|
87
|
+
}
|
|
88
|
+
const key = candidateKey(finding, requalified.prNumber, requalified.file);
|
|
89
|
+
if (addressed.has(key)) {
|
|
90
|
+
return finding;
|
|
91
|
+
}
|
|
92
|
+
stripped++;
|
|
93
|
+
const { requalifiedBy: _dropped, ...rest } = finding;
|
|
94
|
+
strippedFindings.push({ finding: rest, reason: stripReasons.get(key) ?? "not confirmed" });
|
|
95
|
+
return rest;
|
|
96
|
+
});
|
|
97
|
+
return { findings: out, stripped, strippedFindings, cost, tokens, model };
|
|
98
|
+
}
|
|
99
|
+
// @ref LLP 0010#patch-level-confirmation-v2 [implements] — real confirmer: fetch just the cited file's patch (fail-open null → strip), inline it into the no-tools stack verifier
|
|
100
|
+
/**
|
|
101
|
+
* Build the real per-candidate confirmer: fetch just the cited file's patch from the
|
|
102
|
+
* addressing PR (the source fails open to `null` → treated as "not addressed"), inline
|
|
103
|
+
* it into the no-tools stack verifier, and require `addressed: true`. The patch is
|
|
104
|
+
* inlined, never materialized — there is no disk read and no tool use at all.
|
|
105
|
+
*/
|
|
106
|
+
export function patchConfirmer(handle, source) {
|
|
107
|
+
// Per-run patch memo: distinct findings citing the same (prNumber, file) each get
|
|
108
|
+
// their own verdict but share one gh fetch. The source fails open to null (never
|
|
109
|
+
// rejects), so memoizing the promise is safe.
|
|
110
|
+
const patches = new Map();
|
|
111
|
+
return async ({ prNumber, file, finding }) => {
|
|
112
|
+
const patchKey = manifestKey(prNumber, file);
|
|
113
|
+
let patchPromise = patches.get(patchKey);
|
|
114
|
+
if (!patchPromise) {
|
|
115
|
+
patchPromise = source.getStackFilePatchAsync
|
|
116
|
+
? source.getStackFilePatchAsync(prNumber, file)
|
|
117
|
+
: Promise.resolve(null);
|
|
118
|
+
patches.set(patchKey, patchPromise);
|
|
119
|
+
}
|
|
120
|
+
const patch = await patchPromise;
|
|
121
|
+
if (patch == null) {
|
|
122
|
+
// No patch for the cited file (or the source can't fetch it): not confirmable.
|
|
123
|
+
return { addressed: false, cost: 0, tokens: {} };
|
|
124
|
+
}
|
|
125
|
+
const { value, cost, tokens, model } = await promptAndParse(handle, {
|
|
126
|
+
agent: STACK_VERIFIER_AGENT,
|
|
127
|
+
system: buildStackVerifierSystem(),
|
|
128
|
+
text: buildStackVerifierTask(finding, prNumber, patch),
|
|
129
|
+
title: `stack-verify-${prNumber}`,
|
|
130
|
+
maxWaitMs: STACK_CONFIRM_TIMEOUT_MS,
|
|
131
|
+
// A timeout here throws AgentTimeoutError, which the caller catches and STRIPS
|
|
132
|
+
// (fail toward blocking) — so no finalize salvage, unlike the main verifier.
|
|
133
|
+
finalizeOnTimeout: false,
|
|
134
|
+
}, parseStackVerdict);
|
|
135
|
+
return { addressed: value.addressed === true, cost, tokens, model };
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-root-relative normalization for exact manifest membership: strip a leading
|
|
3
|
+
* `./` or `/`, trim. No substrings, no basenames — the unsound-matching critique
|
|
4
|
+
* (`a.ts` must not match `data.ts`). Grounding and confirmation both key on this.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeManifestPath(file) {
|
|
7
|
+
return file
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/^\.\/+/, "")
|
|
10
|
+
.replace(/^\/+/, "");
|
|
11
|
+
}
|
|
12
|
+
/** Membership/dedupe key for a cited `(prNumber, file)`. */
|
|
13
|
+
export function manifestKey(prNumber, file) {
|
|
14
|
+
return `${prNumber} ${normalizeManifestPath(file)}`;
|
|
15
|
+
}
|
|
16
|
+
/** The set of every `(prNumber, file)` the manifest actually lists. */
|
|
17
|
+
export function buildManifestMembership(manifest) {
|
|
18
|
+
const members = new Set();
|
|
19
|
+
for (const pr of manifest.upstackPRs) {
|
|
20
|
+
for (const file of pr.files) {
|
|
21
|
+
members.add(manifestKey(pr.number, file));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return members;
|
|
25
|
+
}
|
package/build/core/suppress.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
// @ref LLP 0005#inline-suppression-backstop — deterministic backstop for expo-code-review-ignore; used to be prompt-only
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
const DIRECTIVE = "expo-code-review-ignore";
|
|
5
|
+
// @ref LLP 0005#inline-suppression-backstop [constrained-by] — critical/secrets findings are never suppressed this way
|
|
4
6
|
/**
|
|
5
7
|
* Deterministic backstop for the inline `expo-code-review-ignore` directive (which
|
|
6
8
|
* was previously prompt-only, i.e. honored only if the model chose to). Drops a
|
package/build/core/throttle.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0003#retry-taxonomy [implements] — rate-limit evidence: explicit 429 log lines are hard signal; stall + recent evidence means wait, not retry
|
|
1
2
|
import { open } from "node:fs/promises";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -46,6 +47,7 @@ const EVIDENCE_WINDOW_MS = 5 * 60 * 1000;
|
|
|
46
47
|
* is the signal the stall path consults. Fails soft everywhere: a missing or
|
|
47
48
|
* unreadable log yields "no evidence", never an error.
|
|
48
49
|
*/
|
|
50
|
+
// @ref LLP 0003#retry-taxonomy [implements] — fails soft (missing/unreadable log = no evidence, never an error); scans only ERROR lines so a chatty INFO "retry" mention doesn't count
|
|
49
51
|
export class RateLimitWatch {
|
|
50
52
|
file;
|
|
51
53
|
/** Total rate-limit ERROR lines seen this run. */
|
|
@@ -56,6 +58,16 @@ export class RateLimitWatch {
|
|
|
56
58
|
constructor(file = opencodeLogFile()) {
|
|
57
59
|
this.file = file;
|
|
58
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Record rate-limit evidence directly, for an engine that has no log file to
|
|
63
|
+
* scan (the Claude Code CLI surfaces limits per-invocation, not in a log the
|
|
64
|
+
* way the OpenCode server does). Feeds the same `events`/`recentlyLimited`
|
|
65
|
+
* signals `check()` would.
|
|
66
|
+
*/
|
|
67
|
+
note(count = 1) {
|
|
68
|
+
this.events += count;
|
|
69
|
+
this.lastSeenAt = Date.now();
|
|
70
|
+
}
|
|
59
71
|
/** Scan newly-appended log lines for rate-limit evidence. */
|
|
60
72
|
async check() {
|
|
61
73
|
try {
|
package/build/core/util.js
CHANGED
|
@@ -5,6 +5,24 @@ export function sleep(ms) {
|
|
|
5
5
|
export function errorMessage(error) {
|
|
6
6
|
return error instanceof Error ? error.message : String(error);
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* A failure reason safe to post into a PUBLIC PR comment (see `ecr ci`'s failure
|
|
10
|
+
* notices). `run` (core/exec.ts) throws "Command failed: <cmd> <argv>\n<stderr>" (and
|
|
11
|
+
* "Command output exceeded …"), which embeds a subprocess's full command line, its
|
|
12
|
+
* stderr, and absolute runner paths — CI-internal detail of no use to a PR author and
|
|
13
|
+
* exactly the kind of thing that must not leak into an attacker-visible artifact. Those
|
|
14
|
+
* shapes collapse to a generic pointer at the workflow log; our own (argv/path-free)
|
|
15
|
+
* error messages pass through, since they are the actionable ones the fail-fast design
|
|
16
|
+
* means to surface. Every call site still writes the FULL reason to the job's stderr.
|
|
17
|
+
*/
|
|
18
|
+
// @ref LLP 0002#run-log-and-observability-sinks [implements] — subprocess argv/stderr must never leak into PR-facing comments
|
|
19
|
+
export function publicFailureReason(error) {
|
|
20
|
+
const message = errorMessage(error);
|
|
21
|
+
if (/^Command (failed|output exceeded)\b/.test(message)) {
|
|
22
|
+
return "a subprocess (gh, git, or the model CLI) failed — see the workflow logs for details";
|
|
23
|
+
}
|
|
24
|
+
return message;
|
|
25
|
+
}
|
|
8
26
|
/** Collapse whitespace + lowercase — for tolerant code matching / fingerprinting. */
|
|
9
27
|
export function normalizeCode(text) {
|
|
10
28
|
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
package/build/core/verify.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
// @ref LLP 0005#evidence-grounding-escalate-never-hard-drop
|
|
2
|
+
// @ref LLP 0005#verifier-confinement-and-fail-open
|
|
1
3
|
import { readFile } from "node:fs/promises";
|
|
2
4
|
import path from "node:path";
|
|
5
|
+
import { pathInside } from "./exec.js";
|
|
3
6
|
import { parseVerdict } from "./schema.js";
|
|
4
7
|
import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
|
|
5
8
|
import { buildVerifierSystem, buildVerifierTask } from "./prompts.js";
|
|
@@ -9,6 +12,7 @@ import { errorMessage, normalizeCode } from "./util.js";
|
|
|
9
12
|
const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
10
13
|
// Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
|
|
11
14
|
const MIN_EVIDENCE_LEN = 12;
|
|
15
|
+
// @ref LLP 0005#evidence-grounding-escalate-never-hard-drop [implements] — exact-substring is a good positive but poor negative signal (33a970a revert)
|
|
12
16
|
/**
|
|
13
17
|
* Break `evidence` into normalized, substantive fragments for fuzzy matching:
|
|
14
18
|
* split on newlines AND ellipses (the model often elides with `…`/`...`), strip
|
|
@@ -48,17 +52,30 @@ export function matchEvidence(evidence, content) {
|
|
|
48
52
|
}
|
|
49
53
|
return fragments.some((fragment) => normContent.includes(fragment)) ? "present" : "absent";
|
|
50
54
|
}
|
|
55
|
+
// @ref LLP 0005#verifier-confinement-and-fail-open [implements] — pathInside gate: out-of-tree reads (and their present/absent verdict) refused
|
|
51
56
|
/** Read the cited file and grade the evidence against it (see matchEvidence). */
|
|
52
57
|
async function evidencePresence(finding, cwd) {
|
|
58
|
+
// finding.file is an unconstrained, LLM-authored string produced over untrusted PR
|
|
59
|
+
// content, so a prompt-injected finding could point it at a host secret. path.resolve
|
|
60
|
+
// IGNORES cwd when finding.file is already absolute (e.g. ~/.claude/.credentials.json),
|
|
61
|
+
// and `..` segments escape upward — either would make this raw readFile reach outside
|
|
62
|
+
// the reviewed tree with the host user's privileges, and the present/absent grading
|
|
63
|
+
// would leak a content-oracle back into the review. Confine the read to cwd (the
|
|
64
|
+
// materialized PR-head tree); anything outside is uncheckable, never read.
|
|
65
|
+
const resolved = path.resolve(cwd, finding.file);
|
|
66
|
+
if (!pathInside(resolved, cwd)) {
|
|
67
|
+
return "unknown";
|
|
68
|
+
}
|
|
53
69
|
let content;
|
|
54
70
|
try {
|
|
55
|
-
content = await readFile(
|
|
71
|
+
content = await readFile(resolved, "utf8");
|
|
56
72
|
}
|
|
57
73
|
catch {
|
|
58
74
|
return "unknown";
|
|
59
75
|
}
|
|
60
76
|
return matchEvidence(finding.evidence ?? "", content);
|
|
61
77
|
}
|
|
78
|
+
// @ref LLP 0005#verifier-confinement-and-fail-open [constrained-by] — fails open: a verify error/timeout keeps the finding, never drops it
|
|
62
79
|
/**
|
|
63
80
|
* Guard against hallucinated findings before they're surfaced, WITHOUT silently
|
|
64
81
|
* dropping real ones on an imperfect quote:
|