@expo/code-review-cli 0.7.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 +118 -13
- package/build/cli.js +7 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- 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 +3 -0
- 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 +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- 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 +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +255 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +290 -15
- package/build/core/schema.js +213 -2
- package/build/core/scrub.js +4 -0
- 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 +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- 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 +50 -1
- 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 +96 -1
- package/templates/workflow.yml +5 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0007#doctor-and-setup-auth — derives a plan from auth config, then guides local credential acquisition
|
|
1
2
|
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
@@ -65,6 +66,7 @@ export function opencodeAuthJsonPath(env = process.env) {
|
|
|
65
66
|
const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
66
67
|
return path.join(dataHome, "opencode", "auth.json");
|
|
67
68
|
}
|
|
69
|
+
// @ref LLP 0007#doctor-and-setup-auth [constrained-by] — refresh tokens are single-use; they never leave OpenCode's store
|
|
68
70
|
/**
|
|
69
71
|
* The stored ChatGPT sign-in's ACCESS token, if OpenCode has a live one. The
|
|
70
72
|
* refresh token deliberately never leaves OpenCode's store: refresh tokens are
|
|
@@ -102,6 +104,7 @@ async function confirm(question, skip) {
|
|
|
102
104
|
rl.close();
|
|
103
105
|
}
|
|
104
106
|
}
|
|
107
|
+
// @ref LLP 0007#doctor-and-setup-auth [constrained-by] — shell metacharacters in tokens never expand
|
|
105
108
|
/** The line to paste into a shell config. Single-quoted: tokens never contain '. */
|
|
106
109
|
export function exportLine(tokenEnv, value) {
|
|
107
110
|
return `export ${tokenEnv}='${value}'`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0007#verify-config-the-config-guard — the CI trust guard that runs before the loaders are trusted, so it deliberately does not use them
|
|
1
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
|
|
@@ -28,6 +29,7 @@ Options:
|
|
|
28
29
|
--json Emit {ok, findings:[{file, problem}]} on stdout.
|
|
29
30
|
`;
|
|
30
31
|
const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
|
|
32
|
+
// @ref LLP 0007#verify-config-the-config-guard [implements] — on-disk sweep, not git index, not the manifest; CONFIG_FILENAMES must mirror load.ts
|
|
31
33
|
/**
|
|
32
34
|
* Discover every config the CLI could ever read via a plain recursive walk (not
|
|
33
35
|
* `git ls-files`): a PR can't hide an unreferenced/untracked config dir from an
|
|
@@ -183,6 +185,7 @@ export async function verifyConfig(root, options = {}) {
|
|
|
183
185
|
}
|
|
184
186
|
seen.add(occurrence.value);
|
|
185
187
|
}
|
|
188
|
+
// @ref LLP 0007#verify-config-the-config-guard [implements] — exact set equality; adding a credential is refused like repointing one
|
|
186
189
|
// With an expectation set, the declared names must equal the expected SET
|
|
187
190
|
// exactly (comma-separated; order-insensitive). A missing name is as much a
|
|
188
191
|
// finding as an extra one — a PR must not add, drop, or repoint credentials.
|
package/build/config/load.js
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch — shared root/scope loader; ECR_CONFIG_DIR escape hatch
|
|
2
|
+
// @ref LLP 0006#model-resolution — REVIEWER_MODEL env override resolution
|
|
3
|
+
// @ref LLP 0006#auth-config-shapes — auth normalization (normalizeAuth, tokenEnvMismatch, loadAuthFromRoot)
|
|
4
|
+
// @ref LLP 0006#root-vs-scope-config — scope config loading, commentTag derivation, enforceAgents injection
|
|
1
5
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
6
|
import { existsSync } from "node:fs";
|
|
3
7
|
import path from "node:path";
|
|
4
8
|
import { ReviewConfigSchema, ScopeReviewConfigSchema } from "./schema.js";
|
|
5
9
|
import { toolMap } from "../core/tools.js";
|
|
6
10
|
export const CONFIG_DIRNAME = ".expo-code-review";
|
|
11
|
+
/** Stack config for a scope load (where `stack` is schema-rejected and absent). */
|
|
12
|
+
const STACK_CONFIG_DEFAULTS = {
|
|
13
|
+
enabled: false,
|
|
14
|
+
maxDepth: 4,
|
|
15
|
+
maxPrs: 8,
|
|
16
|
+
maxFilesPerPr: 100,
|
|
17
|
+
requireSameAuthor: true,
|
|
18
|
+
confirmWithPatch: false,
|
|
19
|
+
maxConfirmations: 10,
|
|
20
|
+
};
|
|
21
|
+
/** Feedback config for a scope load (where `feedback` is schema-rejected and absent). */
|
|
22
|
+
const FEEDBACK_CONFIG_DEFAULTS = {
|
|
23
|
+
mode: "annotate",
|
|
24
|
+
match: "both",
|
|
25
|
+
dismiss: "never",
|
|
26
|
+
protectedCategories: ["secrets", "security"],
|
|
27
|
+
maxAdjudications: 10,
|
|
28
|
+
};
|
|
7
29
|
/** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
|
|
8
30
|
const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
|
|
9
31
|
export function configDirFor(repoRoot) {
|
|
@@ -61,6 +83,7 @@ async function loadConfigDir(dir, schema) {
|
|
|
61
83
|
// agent and the coordinator then ran on whatever OpenCode picked by default, so a
|
|
62
84
|
// config saying `anthropic/claude-sonnet-5` reviewed with something else entirely and
|
|
63
85
|
// nothing anywhere said so. Trim too: a stray newline is the same class of accident.
|
|
86
|
+
// @ref LLP 0006#model-resolution [constrained-by] — never ??; GitHub Actions passes an unset var as empty string, not undefined
|
|
64
87
|
const override = process.env.REVIEWER_MODEL?.trim() || undefined;
|
|
65
88
|
const defaultModel = override ?? parsed.model;
|
|
66
89
|
const resolveModel = (frontmatterModel) => override ?? frontmatterModel ?? defaultModel;
|
|
@@ -121,6 +144,14 @@ async function loadConfigDir(dir, schema) {
|
|
|
121
144
|
commentTag: parsed.commentTag ?? "expo-ai-code-reviewer",
|
|
122
145
|
auth: normalizeAuth(parsed.auth),
|
|
123
146
|
review: parsed.review,
|
|
147
|
+
// Root-only: the scope schema rejects `stack`, so parsed.stack is absent for a
|
|
148
|
+
// scope config and the defaults stand in (unused — the command layer reads the
|
|
149
|
+
// ROOT config's stack values to drive the walk).
|
|
150
|
+
stack: parsed.stack ?? STACK_CONFIG_DEFAULTS,
|
|
151
|
+
// Root-only: the scope schema rejects `feedback`, so parsed.feedback is absent
|
|
152
|
+
// for a scope config and the defaults stand in (unused — the command layer
|
|
153
|
+
// reads the ROOT config's feedback values; the comment lifecycle is global).
|
|
154
|
+
feedback: parsed.feedback ?? FEEDBACK_CONFIG_DEFAULTS,
|
|
124
155
|
};
|
|
125
156
|
return { config, raw: rawObject };
|
|
126
157
|
}
|
|
@@ -185,6 +216,7 @@ export function loadAuthFromRoot(rootConfig, manifest) {
|
|
|
185
216
|
* with the root config; the scope config activates after merge) instead of
|
|
186
217
|
* failing the run on exactly the PR that introduces the scope.
|
|
187
218
|
*/
|
|
219
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch [implements] — deliberately bypasses ECR_CONFIG_DIR; scope subtrees stay repo-root-relative
|
|
188
220
|
export function hasScopeConfig(root, scope) {
|
|
189
221
|
if (scope.config === ".") {
|
|
190
222
|
return true;
|
|
@@ -224,6 +256,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
224
256
|
// loaded value here is only the manifest default, for display/doctor.
|
|
225
257
|
commentTag = manifest.defaults.commentTag;
|
|
226
258
|
}
|
|
259
|
+
// @ref LLP 0006#root-vs-scope-config [implements] — ROOT enforced agent always wins a same-id scope agent (risk 11)
|
|
227
260
|
// Inject the enforced agents from the ROOT roster with alwaysRun, replacing any
|
|
228
261
|
// same-id agent the scope defines (the enforced one wins — risk 11).
|
|
229
262
|
const agents = base.agents.map((agent) => ({ ...agent }));
|
|
@@ -247,6 +280,12 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
247
280
|
auth: loadAuthFromRoot(rootConfig, manifest),
|
|
248
281
|
breakGlassMarker: rootConfig.breakGlassMarker,
|
|
249
282
|
commentTag,
|
|
283
|
+
// Root-only, like stack: a non-default scope's `base` carries only the
|
|
284
|
+
// hardcoded placeholder (the scope schema rejects `feedback`), so re-derive
|
|
285
|
+
// from the root here or consumers of a nested scope's config would silently
|
|
286
|
+
// run the default policy instead of the repo's real one.
|
|
287
|
+
stack: rootConfig.stack,
|
|
288
|
+
feedback: rootConfig.feedback,
|
|
250
289
|
scopeName: scope.name,
|
|
251
290
|
};
|
|
252
291
|
}
|
package/build/config/routing.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// @ref LLP 0006#routing-manifest — routing.jsonc parsing, scope resolution (last-match-wins), overlaps/unmatched
|
|
2
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch — routing.jsonc travels with config.jsonc via resolveConfigDir
|
|
3
|
+
// @ref LLP 0006#budgets-and-chunking-defaults — per-scope budget split (scopePassesBudgetMs)
|
|
1
4
|
import { readFile } from "node:fs/promises";
|
|
2
5
|
import { existsSync } from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
@@ -15,6 +18,7 @@ export const ROUTING_FILENAME = "routing.jsonc";
|
|
|
15
18
|
* Scope `config` paths stay repo-root-relative (see `loadScopeConfig`): an
|
|
16
19
|
* override relocates only the ROOT artifacts, never the scopes' own subtrees.
|
|
17
20
|
*/
|
|
21
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch [implements] — travels with config.jsonc; a real bug once let them split
|
|
18
22
|
export async function loadRoutingManifest(root, options = {}) {
|
|
19
23
|
const manifestPath = path.join(resolveConfigDir(root, options.configDir), ROUTING_FILENAME);
|
|
20
24
|
if (!existsSync(manifestPath)) {
|
|
@@ -30,6 +34,7 @@ export async function loadRoutingManifest(root, options = {}) {
|
|
|
30
34
|
// silently miss root-level files (README.md, package.json). To give the double-star +
|
|
31
35
|
// slash its conventional "zero or more directories" meaning, we also test the variant
|
|
32
36
|
// with each such prefix removed, so the catch-all matches both `a.ts` and `src/b.ts`.
|
|
37
|
+
// @ref LLP 0006#routing-manifest [constrained-by] — workaround for matchesIgnore's **-needs-a-slash limitation (LLP 0004 dialect)
|
|
33
38
|
function patternVariants(pattern) {
|
|
34
39
|
const collapsed = pattern.replace(/\*\*\//g, "");
|
|
35
40
|
return collapsed !== pattern && collapsed.length > 0 ? [pattern, collapsed] : [pattern];
|
|
@@ -44,6 +49,7 @@ function scopeMatches(paths, file) {
|
|
|
44
49
|
* matchesIgnore (supports ** across / and * within a segment — the manifest
|
|
45
50
|
* documents this dialect). Deterministic, no filesystem access.
|
|
46
51
|
*/
|
|
52
|
+
// @ref LLP 0006#routing-manifest [implements] — last-match-wins (CODEOWNERS discipline); each file lands in exactly one scope
|
|
47
53
|
export function resolveScopes(manifest, changedFiles) {
|
|
48
54
|
const buckets = new Map();
|
|
49
55
|
const unmatched = [];
|
|
@@ -86,6 +92,7 @@ export function scopedCommentTag(rootTag, scopeName) {
|
|
|
86
92
|
* starting — and `overshoot` flags that the run will exceed the total budget so
|
|
87
93
|
* the caller can warn. Pure so the math is unit-testable.
|
|
88
94
|
*/
|
|
95
|
+
// @ref LLP 0006#budgets-and-chunking-defaults [implements] — floor(total/active) clamped to a 5-min floor; overshoot flags the clamp
|
|
89
96
|
export function scopePassesBudgetMs(totalMs, minMs, activeCount) {
|
|
90
97
|
const count = Math.max(1, activeCount);
|
|
91
98
|
const evenSplit = Math.floor(totalMs / count);
|
package/build/config/schema.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
// @ref LLP 0006#root-vs-scope-config — schema for root vs. scope-overridable config keys
|
|
2
|
+
// @ref LLP 0006#auth-config-shapes — auth union schema (legacy single credential + per-provider map)
|
|
3
|
+
// @ref LLP 0006#routing-manifest — routing.jsonc manifest schema (scopes, budgets, traversal guard)
|
|
4
|
+
// @ref LLP 0006#budgets-and-chunking-defaults — chunk/budget default values and re-tuning heuristics
|
|
1
5
|
import path from "node:path";
|
|
2
6
|
import { z } from "zod";
|
|
7
|
+
import { CATEGORIES } from "../core/schema.js";
|
|
3
8
|
export const ReviewConfigSchema = z.object({
|
|
4
9
|
/** Default model for every agent + the coordinator. Override per-agent via
|
|
5
10
|
* frontmatter in the agent's markdown, or globally via REVIEWER_MODEL. */
|
|
@@ -68,6 +73,7 @@ export const ReviewConfigSchema = z.object({
|
|
|
68
73
|
// Union order matters: the map form must be tried FIRST — the legacy object's keys
|
|
69
74
|
// all have defaults, so a non-strict legacy parse would accept (and gut) a
|
|
70
75
|
// { providers } object by stripping the unknown key.
|
|
76
|
+
// @ref LLP 0006#auth-config-shapes [constrained-by] — map-first order is load-bearing; reordering silently guts multi-provider auth
|
|
71
77
|
auth: z
|
|
72
78
|
.union([
|
|
73
79
|
z.object({
|
|
@@ -115,6 +121,77 @@ export const ReviewConfigSchema = z.object({
|
|
|
115
121
|
skipLabel: z.string().default("ai-review:skip"),
|
|
116
122
|
})
|
|
117
123
|
.default({ trigger: "all", label: "ai-review", skipLabel: "ai-review:skip" }),
|
|
124
|
+
// Stack-aware requalification: walk the OPEN PRs stacked on top of this one and
|
|
125
|
+
// let the coordinator mark absence-style findings a later PR already addresses.
|
|
126
|
+
// ROOT-ONLY (one PR has one stack) and off by default — a suppression-adjacent
|
|
127
|
+
// feature earns trust with field data first. Under `ecr ci` it auto-enables from
|
|
128
|
+
// this trusted-base value; `ecr review --pr` needs an explicit --stack-aware.
|
|
129
|
+
// @ref LLP 0010#config-and-cli-surface [implements] — root-only + off-by-default; head config can never enable, widen, or disable it
|
|
130
|
+
stack: z
|
|
131
|
+
.object({
|
|
132
|
+
enabled: z.boolean().default(false),
|
|
133
|
+
maxDepth: z.number().int().positive().default(4),
|
|
134
|
+
// Children per level (per parent branch) the walk will follow.
|
|
135
|
+
maxPrs: z.number().int().positive().default(8),
|
|
136
|
+
maxFilesPerPr: z.number().int().positive().default(100),
|
|
137
|
+
// Only children whose author is the current PR's author enter the manifest —
|
|
138
|
+
// closes cross-author poisoning (a push-access colleague opening a child PR on
|
|
139
|
+
// the victim's branch). Set false from the trusted base for genuine team stacks.
|
|
140
|
+
requireSameAuthor: z.boolean().default(true),
|
|
141
|
+
// v2: confirm each requalification against the addressing PR's actual patch
|
|
142
|
+
// before believing it (a no-tools LLM reads the inlined patch). Default false so
|
|
143
|
+
// v2 ships dark until flipped; maxConfirmations bounds that cost.
|
|
144
|
+
confirmWithPatch: z.boolean().default(false),
|
|
145
|
+
maxConfirmations: z.number().int().positive().default(10),
|
|
146
|
+
})
|
|
147
|
+
.default({
|
|
148
|
+
enabled: false,
|
|
149
|
+
maxDepth: 4,
|
|
150
|
+
maxPrs: 8,
|
|
151
|
+
maxFilesPerPr: 100,
|
|
152
|
+
requireSameAuthor: true,
|
|
153
|
+
confirmWithPatch: false,
|
|
154
|
+
maxConfirmations: 10,
|
|
155
|
+
}),
|
|
156
|
+
// Author replies to findings: match them to the finding they answer, record
|
|
157
|
+
// them in the comment's embedded state, and (optionally) let a model judge the
|
|
158
|
+
// rebuttal against the source. ROOT-ONLY: the comment lifecycle is global.
|
|
159
|
+
// Defaults are deliberately ASYMMETRIC: `annotate` is on but `dismiss` is off.
|
|
160
|
+
// An adopting repo has its own config.jsonc and never re-copies this template,
|
|
161
|
+
// so a key it never set must still resolve to the safe, useful default via
|
|
162
|
+
// zod — annotating is safe and useful out of the box; suppressing a finding is
|
|
163
|
+
// not, so it stays opt-in.
|
|
164
|
+
// @ref LLP 0011#asymmetric-defaults [implements] — annotate on, dismiss off; adopting repos never re-copy the template
|
|
165
|
+
feedback: z
|
|
166
|
+
.object({
|
|
167
|
+
// "off" — ignore replies entirely.
|
|
168
|
+
// "annotate" — match + record + show "author replied" (no decision effect).
|
|
169
|
+
// "adjudicate" — also run a source-grounded judgment of the rebuttal and
|
|
170
|
+
// record its verdict. Dismissal still obeys `dismiss`.
|
|
171
|
+
mode: z.enum(["off", "annotate", "adjudicate"]).default("annotate"),
|
|
172
|
+
// How a reply is MATCHED to a finding. Clearing one additionally requires the
|
|
173
|
+
// reply to cite its `id:` token in the replier's own words, whatever this says.
|
|
174
|
+
match: z.enum(["quote", "id", "both"]).default("both"),
|
|
175
|
+
// Who/what may actually remove a finding from the blocking set (always on a
|
|
176
|
+
// reply citing the finding's `id:` token — a quote only annotates):
|
|
177
|
+
// "never" — nothing does (default: adjudication ships dark).
|
|
178
|
+
// "maintainers" — a maintainer reply dismisses, no model involved.
|
|
179
|
+
// "adjudicated" — a maintainer reply, or an author reply the adjudicator
|
|
180
|
+
// confirmed against the source.
|
|
181
|
+
dismiss: z.enum(["never", "maintainers", "adjudicated"]).default("never"),
|
|
182
|
+
// Categories a reply can NEVER clear, whatever the verdict. Also hard-coded
|
|
183
|
+
// as a floor in code — this only widens the set, never narrows it.
|
|
184
|
+
protectedCategories: z.array(z.enum(CATEGORIES)).default(["secrets", "security"]),
|
|
185
|
+
// Cap on adjudication model calls per run.
|
|
186
|
+
maxAdjudications: z.number().int().positive().default(10),
|
|
187
|
+
})
|
|
188
|
+
.default({
|
|
189
|
+
mode: "annotate",
|
|
190
|
+
match: "both",
|
|
191
|
+
dismiss: "never",
|
|
192
|
+
protectedCategories: ["secrets", "security"],
|
|
193
|
+
maxAdjudications: 10,
|
|
194
|
+
}),
|
|
118
195
|
});
|
|
119
196
|
/** One routing scope: ordered globs → a directory containing .expo-code-review/. */
|
|
120
197
|
export const RoutingScopeSchema = z.object({
|
|
@@ -126,6 +203,7 @@ export const RoutingScopeSchema = z.object({
|
|
|
126
203
|
* routing.jsonc is read from the PR-head checkout, so this field is
|
|
127
204
|
* PR-controllable input: absolute paths and `..` traversal are rejected so a
|
|
128
205
|
* scope config can never resolve outside the repo. */
|
|
206
|
+
// @ref LLP 0006#routing-manifest [implements] — traversal guard; load.ts re-checks at runtime (defense in depth)
|
|
129
207
|
config: z
|
|
130
208
|
.string()
|
|
131
209
|
.min(1)
|
|
@@ -159,6 +237,7 @@ export const RoutingManifestSchema = z
|
|
|
159
237
|
* chain still fires the default when the key is absent, which would make
|
|
160
238
|
* `defaults.auth` a phantom `{mode:'api-key',provider:'openai'}` for every
|
|
161
239
|
* manifest that omits auth and silently override the root config's real auth. */
|
|
240
|
+
// @ref LLP 0006#routing-manifest [constrained-by] — zod v4 default().optional() trap; unwrap avoids a phantom auth stub
|
|
162
241
|
auth: ReviewConfigSchema.shape.auth.unwrap().optional(),
|
|
163
242
|
/** Agent ids injected into every scope with alwaysRun, from the ROOT roster. */
|
|
164
243
|
enforceAgents: z.array(z.string()).default([]),
|
|
@@ -206,10 +285,13 @@ export const RoutingManifestSchema = z
|
|
|
206
285
|
* standalone `ecr review --scope --post` always target the same marker — an
|
|
207
286
|
* honored per-scope tag would let the two halves strand each other's comments.
|
|
208
287
|
*/
|
|
288
|
+
// @ref LLP 0006#root-vs-scope-config [implements] — one of three enforcement layers; z.never fails at parse, not runtime
|
|
209
289
|
export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
210
290
|
auth: true,
|
|
211
291
|
breakGlass: true,
|
|
212
292
|
commentTag: true,
|
|
293
|
+
stack: true,
|
|
294
|
+
feedback: true,
|
|
213
295
|
}).extend({
|
|
214
296
|
auth: z
|
|
215
297
|
.never({ error: "auth is locked to the root config; remove it from this scope config" })
|
|
@@ -220,4 +302,14 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
|
220
302
|
error: "commentTag is locked: per-scope comment markers are derived as <rootTag>:<scope>; remove it from this scope config",
|
|
221
303
|
})
|
|
222
304
|
.optional(),
|
|
305
|
+
stack: z
|
|
306
|
+
.never({
|
|
307
|
+
error: "stack is locked to the root config (one PR has one stack); remove it from this scope config",
|
|
308
|
+
})
|
|
309
|
+
.optional(),
|
|
310
|
+
feedback: z
|
|
311
|
+
.never({
|
|
312
|
+
error: "feedback is locked to the root config (the comment lifecycle is global); remove it from this scope config",
|
|
313
|
+
})
|
|
314
|
+
.optional(),
|
|
223
315
|
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
|
|
2
|
+
import { buildAdjudicatorSystem, buildAdjudicatorTask } from "./prompts.js";
|
|
3
|
+
import { parseAdjudication } from "./schema.js";
|
|
4
|
+
import { errorMessage } from "./util.js";
|
|
5
|
+
// Adjudication runs after coordination/verification (a serial tail step) and in
|
|
6
|
+
// parallel over records; keep the per-call bound in the shape of the main verifier's
|
|
7
|
+
// VERIFY_TIMEOUT_MS. A call that runs long finalizes on whatever it has.
|
|
8
|
+
const ADJUDICATE_TIMEOUT_MS = 3 * 60 * 1000;
|
|
9
|
+
// @ref LLP 0011#hard-floors-in-code [implements] — secrets/security can NEVER be cleared by a reply, whatever the config; a code floor `protectedCategories` can only widen, never narrow
|
|
10
|
+
/** Categories no reply can ever clear, independent of the configured protected set. */
|
|
11
|
+
export const HARD_FLOOR_CATEGORIES = ["secrets", "security"];
|
|
12
|
+
/**
|
|
13
|
+
* Whether no config — current or future — can ever let a reply clear this finding.
|
|
14
|
+
* `feedbackApplied`'s floor and `adjudicateFeedback`'s "don't spend a model call on it"
|
|
15
|
+
* skip are the same question, so they read it from one place and cannot drift.
|
|
16
|
+
* Deliberately NOT `config.protectedCategories`/`dismiss`: those are user-tunable, so a
|
|
17
|
+
* stored verdict on such a finding becomes useful the moment the config changes.
|
|
18
|
+
*/
|
|
19
|
+
function hardFloored(finding) {
|
|
20
|
+
return finding.severity === "critical" || HARD_FLOOR_CATEGORIES.includes(finding.category);
|
|
21
|
+
}
|
|
22
|
+
// @ref LLP 0011#hard-floors-in-code [constrained-by] — dismissal is gated by `dismiss` alone (default "never"); critical + secrets/security are floored in code, and a prompt-injected verdict can't clear them
|
|
23
|
+
/**
|
|
24
|
+
* Whether a reply actually removes this finding from the blocking set. `dismiss` is the
|
|
25
|
+
* one knob that gates clearing — it defaults to "never", so suppression is always an
|
|
26
|
+
* explicit opt-in. `mode` is a separate axis (how much machinery runs), so
|
|
27
|
+
* `dismiss: "maintainers"` works under `mode: "annotate"` with no model involved, the
|
|
28
|
+
* same trust gate as `/dismiss`. NEVER for a critical / secrets / security / protected
|
|
29
|
+
* finding — those floors live here in code, not in any prompt. A maintainer reply that
|
|
30
|
+
* CITES the finding's id clears; the PR author's cited reply clears only under
|
|
31
|
+
* `dismiss: "adjudicated"` with an "accepted" verdict. An untrusted third-party
|
|
32
|
+
* commenter (neither maintainer nor PR author) never clears — its reply is annotated
|
|
33
|
+
* only, as is any reply that merely quotes the finding.
|
|
34
|
+
*/
|
|
35
|
+
export function feedbackApplied(finding, record, config) {
|
|
36
|
+
if (config.mode === "off" || config.dismiss === "never") {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
// @ref LLP 0011#a-quote-annotates-an-id-clears [implements] — a quote may annotate, only a cited id may clear, on BOTH clear paths below
|
|
40
|
+
// A quoted line is not consent: GitHub's "Quote reply" copies a comment the untrusted
|
|
41
|
+
// PR author wrote, so a maintainer can clear-by-accident (or be led to) on text they
|
|
42
|
+
// never authored. The `id:<fp>` token is printed only by our own comment and counts
|
|
43
|
+
// only outside a blockquote, so citing it is an act of the replier.
|
|
44
|
+
if (record.citedId !== true) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// A human ran `/undismiss` on this reply-cleared finding: it is pinned back to the
|
|
48
|
+
// active list, so the still-present reply must not silently re-clear it.
|
|
49
|
+
if (record.unclearedByHuman) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (hardFloored(finding)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (config.protectedCategories.includes(finding.category)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (record.maintainer) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
// The adjudicated path is the PR author's alone. `record.author` is re-derived from
|
|
62
|
+
// the live comment's unspoofable login every run, so a random PR commenter — even
|
|
63
|
+
// with a model-accepted rebuttal — can never clear a finding.
|
|
64
|
+
return (config.dismiss === "adjudicated" && record.verdict === "accepted" && record.author === true);
|
|
65
|
+
}
|
|
66
|
+
// @ref LLP 0011#suppression-is-never-silent [implements] — a verdict binds to the source it judged; both merge paths (reporter + aggregate) share THIS predicate so they can never drift apart
|
|
67
|
+
/**
|
|
68
|
+
* Strip a stored decision that no longer judges the source under review. A verdict is a
|
|
69
|
+
* claim about CODE, not only about words, and `fingerprintFinding` deliberately excludes
|
|
70
|
+
* the line number, so the author can edit away the code a rebuttal relied on while the
|
|
71
|
+
* finding keeps its identity. Unknown head — no `headSha` for this run, or a record
|
|
72
|
+
* written before the field existed — counts as different, never as trusted. A record
|
|
73
|
+
* with no verdict (a maintainer reply, an unjudged annotation) has no source-dependent
|
|
74
|
+
* decision and passes through untouched. The stale `applied` drops with the verdict:
|
|
75
|
+
* carrying `true` would hide the finding for one render before the recompute.
|
|
76
|
+
*/
|
|
77
|
+
export function dropStaleVerdict(record, headSha) {
|
|
78
|
+
if (record.verdict === undefined || (headSha !== undefined && record.sourceSha === headSha)) {
|
|
79
|
+
return record;
|
|
80
|
+
}
|
|
81
|
+
const { verdict: _verdict, reason: _reason, sourceSha: _sourceSha, ...rest } = record;
|
|
82
|
+
return { ...rest, applied: false };
|
|
83
|
+
}
|
|
84
|
+
// @ref LLP 0011#hard-floors-in-code [constrained-by] — the extra `gh pr view` runs only where the author flag can move an outcome; a skipped lookup reads exactly like a failed one (author:false, clears nothing)
|
|
85
|
+
/**
|
|
86
|
+
* Whether the PR author's login has to be resolved at all for this config. The `author`
|
|
87
|
+
* flag gates exactly one thing — the adjudicated clear path in `feedbackApplied` (and,
|
|
88
|
+
* through it, which replies are worth a model call) — so under any other `dismiss`
|
|
89
|
+
* value resolving it is a `gh` call that can never move an outcome. Absent config (the
|
|
90
|
+
* `ecr feedback` crawl) resolves nothing, which is also the fail-closed answer: an
|
|
91
|
+
* unresolved author marks no reply as the author's and clears nothing.
|
|
92
|
+
*/
|
|
93
|
+
export function feedbackNeedsPrAuthor(config) {
|
|
94
|
+
return config?.dismiss === "adjudicated";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether the command layer must wire the runReview feedback seam at all: either a
|
|
98
|
+
* model judges replies ("adjudicate"), or replies may clear findings (`dismiss` opted
|
|
99
|
+
* in) and `applied` must be computed here even though no model runs. Everything else
|
|
100
|
+
* (annotate + never) is handled by the reporter at report time, with no seam.
|
|
101
|
+
*/
|
|
102
|
+
export function feedbackNeedsRunSeam(config) {
|
|
103
|
+
return config.mode === "adjudicate" || (config.mode !== "off" && config.dismiss !== "never");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Judge each author reply against the source and record its verdict, then recompute
|
|
107
|
+
* every record's `applied` flag under the hard floors. Bounded: at most
|
|
108
|
+
* `config.maxAdjudications` model calls per run, run in parallel; records past the cap
|
|
109
|
+
* are left unjudged and counted in `skipped` (never silently dropped). Fails open — a
|
|
110
|
+
* model/parse/timeout error on one record keeps it (verdict unset) and never throws.
|
|
111
|
+
* When mode !== "adjudicate" it makes no model calls at all and only recomputes
|
|
112
|
+
* `applied` — under `dismiss: "maintainers"` a maintainer reply still clears without
|
|
113
|
+
* any model; under `dismiss: "never"` nothing does.
|
|
114
|
+
*
|
|
115
|
+
* `sourceSha` is the head commit this run reviewed: every verdict decided here is
|
|
116
|
+
* stamped with it, so a later run can tell whether the verdict still judges the same
|
|
117
|
+
* source (see mergeFeedback). A source with no resolvable head OID (a local run) stamps
|
|
118
|
+
* nothing, which makes the verdict non-carrying rather than permanent.
|
|
119
|
+
*/
|
|
120
|
+
export async function adjudicateFeedback(handle, items, config, debug = () => { }, sourceSha) {
|
|
121
|
+
let cost = 0;
|
|
122
|
+
let model;
|
|
123
|
+
const tokens = {};
|
|
124
|
+
let failed = 0;
|
|
125
|
+
// Only judge in "adjudicate" mode, and only records without a verdict already decided
|
|
126
|
+
// on a prior run (the reporter carries those forward — re-judging would re-spend the
|
|
127
|
+
// budget on the same words). A reply with no text can't be judged. Only the PR
|
|
128
|
+
// author's replies are worth judging: a maintainer clears without any verdict, and a
|
|
129
|
+
// third-party commenter can never clear (feedbackApplied), so judging either would
|
|
130
|
+
// spend the budget on a rebuttal that changes no outcome. A reply that only quotes the
|
|
131
|
+
// finding can never clear it either, and would otherwise starve a cited reply of the
|
|
132
|
+
// cap. A finding a human already restored via `/undismiss` is likewise left unjudged.
|
|
133
|
+
// A hard-floored finding is skipped for the same reason and is deliberately NOT counted
|
|
134
|
+
// in `skipped` below: that figure means "reduced coverage, raise the cap", and a verdict
|
|
135
|
+
// no config could ever act on is not coverage a higher cap would buy back.
|
|
136
|
+
// @ref LLP 0011#hard-floors-in-code [constrained-by] — the code floor also means the model never judges a critical/secrets/security rebuttal: no verdict could move that outcome
|
|
137
|
+
const toJudge = config.mode === "adjudicate"
|
|
138
|
+
? items.filter((item) => item.record.verdict === undefined &&
|
|
139
|
+
item.record.author === true &&
|
|
140
|
+
item.record.citedId === true &&
|
|
141
|
+
!item.record.unclearedByHuman &&
|
|
142
|
+
!hardFloored(item.finding) &&
|
|
143
|
+
item.replyText.trim() !== "")
|
|
144
|
+
: [];
|
|
145
|
+
// Never truncate silently: the first `maxAdjudications` are judged, the rest are
|
|
146
|
+
// reported as skipped so the caller can surface reduced coverage.
|
|
147
|
+
const within = toJudge.slice(0, config.maxAdjudications);
|
|
148
|
+
const skipped = toJudge.length - within.length;
|
|
149
|
+
if (skipped > 0) {
|
|
150
|
+
debug(`Feedback: ${skipped} repl${skipped === 1 ? "y" : "ies"} over ` +
|
|
151
|
+
`maxAdjudications=${config.maxAdjudications} — left unjudged this run.`);
|
|
152
|
+
}
|
|
153
|
+
// A successful call records the verdict against the record it judged; an error leaves
|
|
154
|
+
// it out of the map, so the record below stays untouched (fail open).
|
|
155
|
+
const judged = new Map();
|
|
156
|
+
await Promise.all(within.map(async (item, index) => {
|
|
157
|
+
try {
|
|
158
|
+
const { value, cost: callCost, tokens: callTokens, model: callModel, } = await promptAndParse(handle, {
|
|
159
|
+
// Reuse the verifier's OpenCode agent: it carries exactly the read+grep tool
|
|
160
|
+
// set the adjudicator needs (open the cited file, trace the path) and the
|
|
161
|
+
// reviewing model, and its distrust posture matches. The system prompt below
|
|
162
|
+
// is what makes this an adjudication rather than a verification.
|
|
163
|
+
agent: VERIFIER_AGENT,
|
|
164
|
+
system: buildAdjudicatorSystem(),
|
|
165
|
+
text: buildAdjudicatorTask(item.finding, item.replyText),
|
|
166
|
+
title: `adjudicate-${index}`,
|
|
167
|
+
maxWaitMs: ADJUDICATE_TIMEOUT_MS,
|
|
168
|
+
finalizeOnTimeout: true,
|
|
169
|
+
}, parseAdjudication);
|
|
170
|
+
cost += callCost;
|
|
171
|
+
addTokenUsage(tokens, callTokens);
|
|
172
|
+
model = callModel ?? model;
|
|
173
|
+
judged.set(item.record, { verdict: value.verdict, reason: value.reason });
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// Fail open: an error leaves the record unjudged and the finding intact.
|
|
177
|
+
failed++;
|
|
178
|
+
debug(`Feedback: could not adjudicate a reply (${errorMessage(error)}); leaving it unjudged.`);
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
// Re-emit every record: set the verdict/reason where judged, then recompute `applied`
|
|
182
|
+
// under the hard floors (this also clears a maintainer reply, which needs no verdict).
|
|
183
|
+
const records = items.map((item) => {
|
|
184
|
+
const decision = judged.get(item.record);
|
|
185
|
+
// A fresh verdict is stamped with the source it judged (when the run knows it), so
|
|
186
|
+
// the next run re-judges the same reply once that source moves on.
|
|
187
|
+
// @ref LLP 0011#suppression-is-never-silent [implements] — the verdict is bound to the revision it was decided against
|
|
188
|
+
const withVerdict = decision
|
|
189
|
+
? { ...item.record, verdict: decision.verdict, reason: decision.reason, sourceSha }
|
|
190
|
+
: item.record;
|
|
191
|
+
return { ...withVerdict, applied: feedbackApplied(item.finding, withVerdict, config) };
|
|
192
|
+
});
|
|
193
|
+
return { records, cost, tokens, model, adjudicated: judged.size, skipped, failed };
|
|
194
|
+
}
|
package/build/core/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — deny-list, cross-provider guard, isolated OAuth staging, and the forwarding-site recheck for Claude Code
|
|
1
2
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -32,7 +33,7 @@ const ANTHROPIC_TOKEN_ENVS = {
|
|
|
32
33
|
* minted for that provider, so we hard-refuse these well-known unrelated secrets.
|
|
33
34
|
* Defense-in-depth alongside loading config only from the trusted base ref.
|
|
34
35
|
*/
|
|
35
|
-
const FORBIDDEN_TOKEN_ENVS = new Set([
|
|
36
|
+
export const FORBIDDEN_TOKEN_ENVS = new Set([
|
|
36
37
|
"GITHUB_TOKEN",
|
|
37
38
|
"GH_TOKEN",
|
|
38
39
|
"ACTIONS_RUNTIME_TOKEN",
|
|
@@ -154,6 +155,7 @@ export function checkOauthTokenShape(provider, token, tokenEnv) {
|
|
|
154
155
|
}
|
|
155
156
|
return ok;
|
|
156
157
|
}
|
|
158
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — two deny checks: FORBIDDEN_TOKEN_ENVS refuses well-known unrelated secrets; the cross-provider ownership guard refuses a non-anthropic entry naming an ANTHROPIC_TOKEN_ENVS var
|
|
157
159
|
/**
|
|
158
160
|
* Decide whether ONE configured credential is usable, WITHOUT mutating the
|
|
159
161
|
* environment. See checkProviderAuth for the all-entries wrapper.
|
|
@@ -398,6 +400,7 @@ export function jwtExpiryMs(token) {
|
|
|
398
400
|
* setup-token style bearers), far-future expiry so OpenCode never tries to
|
|
399
401
|
* refresh a credential that has no refresh half.
|
|
400
402
|
*/
|
|
403
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — JWT access tokens are used as-is and never refreshed; opaque tokens are stored with expires:0 for the codex refresh flow, only safe when this run is the token's sole consumer (refresh tokens are single-use)
|
|
401
404
|
export function oauthAuthJsonEntry(provider, token) {
|
|
402
405
|
if (provider === "openai" && !isJwtAccessToken(token)) {
|
|
403
406
|
return { type: "oauth", access: "", refresh: token, expires: 0 };
|
|
@@ -420,6 +423,7 @@ export function oauthAuthJsonEntry(provider, token) {
|
|
|
420
423
|
* per-provider shapes). Isolated so it never touches the developer's real
|
|
421
424
|
* auth.json.
|
|
422
425
|
*/
|
|
426
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — stages all OAuth credentials into one isolated auth.json under a temp XDG_DATA_HOME; anthropic never passes through this path (its credential goes straight into startClaudeCode's child env)
|
|
423
427
|
export async function prepareAuth(config) {
|
|
424
428
|
const noop = { cleanup: async () => { } };
|
|
425
429
|
// REVIEWER_MODEL is an explicit "use this model with my own creds" override — a
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
// @ref LLP 0003#claude-code-cli-containment [implements] — argv/env hardening for the claude -p subprocess
|
|
2
|
+
// @ref LLP 0003#two-engines-per-agent-dispatch [implements] — anthropic/* routing and the per-agent engine map
|
|
1
3
|
import { tmpdir } from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { checkAuthEntry } from "./auth.js";
|
|
4
6
|
import { pathInside, resolveOnPath, run } from "./exec.js";
|
|
5
|
-
import { addTokenUsage, AgentTimeoutError, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, VERIFIER_AGENT, withTransientRetry, } from "./opencode.js";
|
|
7
|
+
import { addTokenUsage, AgentTimeoutError, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, STACK_VERIFIER_AGENT, VERIFIER_AGENT, withTransientRetry, } from "./opencode.js";
|
|
6
8
|
import { RateLimitWatch } from "./throttle.js";
|
|
7
9
|
/** Coarse per-pass wander bound; the review's own maxWaitMs is the real ceiling. */
|
|
8
10
|
const CLAUDE_MAX_TURNS = 60;
|
|
@@ -26,6 +28,7 @@ const READ_TOOL_MAP = {
|
|
|
26
28
|
glob: "Glob",
|
|
27
29
|
};
|
|
28
30
|
const ALL_READ_TOOLS = ["Read", "Grep", "Glob"];
|
|
31
|
+
// @ref LLP 0003#claude-code-cli-containment [implements] — deny enumeration (not allow-only) because an empty/absent --allowedTools list default-ALLOWS reads; verified against claude 2.1.212, revisit on every CLI version bump
|
|
29
32
|
/**
|
|
30
33
|
* Tools never available to a review pass, whatever the role. A DENY enumeration is
|
|
31
34
|
* the only workable containment: permission rules cannot fail closed here — reads
|
|
@@ -102,6 +105,7 @@ const MISSING_CLI_MESSAGE = "The `claude` CLI is not installed. Install Claude C
|
|
|
102
105
|
* served by the CLI; the retired anthropic-via-OpenCode x-api-key path no longer
|
|
103
106
|
* exists (the CLI accepts an API key too).
|
|
104
107
|
*/
|
|
108
|
+
// @ref LLP 0003#two-engines-per-agent-dispatch [implements] — engine choice is a pure function of the model id's provider prefix, no run-level auth-mode switch
|
|
105
109
|
export function engineForModel(model) {
|
|
106
110
|
const slash = model.indexOf("/");
|
|
107
111
|
const provider = slash > 0 ? model.slice(0, slash) : model;
|
|
@@ -132,6 +136,8 @@ export function buildEngineMap(config, agents = config.agents) {
|
|
|
132
136
|
const shared = config.agents[0]?.model ?? config.coordinator.model;
|
|
133
137
|
modelOf[CROSS_CUTTING_AGENT] = shared;
|
|
134
138
|
modelOf[VERIFIER_AGENT] = shared;
|
|
139
|
+
// @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — the id MUST live in modelOf/engineOf or a claude-routed run dispatches against an undefined handle and crashes
|
|
140
|
+
modelOf[STACK_VERIFIER_AGENT] = shared;
|
|
135
141
|
modelOf["coordinator"] = config.coordinator.model;
|
|
136
142
|
const engineOf = {};
|
|
137
143
|
for (const [id, model] of Object.entries(modelOf)) {
|
|
@@ -330,6 +336,7 @@ export function usageLimitResetMs(errorText) {
|
|
|
330
336
|
* reset time instead. The interpolated reset epoch is a long digit run with no
|
|
331
337
|
* internal word boundary, so it can't spuriously match `\b429\b`/`\b50x\b`.
|
|
332
338
|
*/
|
|
339
|
+
// @ref LLP 0003#retry-taxonomy [constrained-by] — message text deliberately avoids matching isTransientApiError's regex so a subscription cap fails fast rather than retrying
|
|
333
340
|
export function usageLimitMessage(errorText) {
|
|
334
341
|
const resetMs = usageLimitResetMs(errorText);
|
|
335
342
|
const when = resetMs ? new Date(resetMs).toISOString() : "later";
|
|
@@ -577,6 +584,7 @@ export function claudeTokenCredential(entry, env = process.env) {
|
|
|
577
584
|
}
|
|
578
585
|
return { value, kind: value.startsWith("sk-ant-oat") ? "oauth" : "api-key" };
|
|
579
586
|
}
|
|
587
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — re-runs checkAuthEntry at the forwarding site because REVIEWER_MODEL bypasses prepareAuth/checkProviderAuth entirely
|
|
580
588
|
/** Start the Claude Code engine: resolve the CLI and build the subscription env. */
|
|
581
589
|
export async function startClaudeCode(config) {
|
|
582
590
|
const cliPath = await resolveOnPath("claude");
|
|
@@ -659,6 +667,9 @@ export async function startClaudeCode(config) {
|
|
|
659
667
|
// consolidates findings and needs no repo tools.
|
|
660
668
|
tools[CROSS_CUTTING_AGENT] = ["read", "grep"];
|
|
661
669
|
tools[VERIFIER_AGENT] = ["read", "grep"];
|
|
670
|
+
// No tools: the addressing PR's patch is inlined into the task, so the stack
|
|
671
|
+
// verifier never reads the disk (mirrors the coordinator's empty list).
|
|
672
|
+
tools[STACK_VERIFIER_AGENT] = [];
|
|
662
673
|
tools["coordinator"] = [];
|
|
663
674
|
const defaultModel = config.agents[0]?.model ?? config.coordinator.model;
|
|
664
675
|
return {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
// @ref LLP 0007#ecr-ci-the-trusted-root-run [constrained-by] — a missing/oversized context file WARNs and continues in ci; never fails checks
|
|
3
|
+
/**
|
|
4
|
+
* Hard read ceiling for a `--context-file`. The file is read once in the command
|
|
5
|
+
* layer, byte-bounded here, then head/tail capped again for the prompt
|
|
6
|
+
* (CONTEXT_FILE_MAX_CHARS in prompts.ts). This ceiling bounds the read itself so a
|
|
7
|
+
* multi-gigabyte path can't exhaust memory before the prompt cap ever applies.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_CONTEXT_FILE_BYTES = 1_048_576; // 1 MiB
|
|
10
|
+
const READ_CHUNK_BYTES = 65_536;
|
|
11
|
+
/**
|
|
12
|
+
* Read an external context file as UTF-8 text. Throws on a missing/unreadable path
|
|
13
|
+
* or one over MAX_CONTEXT_FILE_BYTES — the command layer decides whether that is
|
|
14
|
+
* fatal (`ecr review`) or a warn-and-continue (`ecr ci`). The ceiling is enforced
|
|
15
|
+
* DURING the read, not by a stat beforehand: special files (`/dev/zero`, proc
|
|
16
|
+
* entries) report a small or zero size but read without end, and a regular file
|
|
17
|
+
* can grow between a stat and the read. Invalid UTF-8 decodes lossily
|
|
18
|
+
* (replacement chars); control chars are stripped later by sanitizeUntrusted.
|
|
19
|
+
*/
|
|
20
|
+
export async function readContextFile(filePath) {
|
|
21
|
+
const handle = await open(filePath, "r");
|
|
22
|
+
try {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (;;) {
|
|
26
|
+
const chunk = Buffer.alloc(READ_CHUNK_BYTES);
|
|
27
|
+
const { bytesRead } = await handle.read(chunk, 0, READ_CHUNK_BYTES);
|
|
28
|
+
if (bytesRead === 0) {
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
total += bytesRead;
|
|
32
|
+
if (total > MAX_CONTEXT_FILE_BYTES) {
|
|
33
|
+
throw new Error(`context file too large (> 1 MiB): ${filePath}`);
|
|
34
|
+
}
|
|
35
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
36
|
+
}
|
|
37
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
}
|
|
42
|
+
}
|