adversarial-review-gate 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/gate.js +21 -5
- package/src/core/load-config.js +82 -0
- package/src/core/process.js +36 -2
- package/src/reviewers/_shared.js +19 -4
package/package.json
CHANGED
package/src/core/gate.js
CHANGED
|
@@ -304,10 +304,15 @@ function baseNameOf(canonical) {
|
|
|
304
304
|
// changed file. Returns null when coverage is acceptable, or an error reason.
|
|
305
305
|
//
|
|
306
306
|
// Both the verdict's `coverage.files_examined` citations and the reviewable
|
|
307
|
-
// changed-file paths are CANONICALIZED before comparison (see canonicalizePath)
|
|
308
|
-
//
|
|
309
|
-
// its basename appears
|
|
310
|
-
//
|
|
307
|
+
// changed-file paths are CANONICALIZED before comparison (see canonicalizePath).
|
|
308
|
+
// A changed file is considered covered if its full canonical path appears in the
|
|
309
|
+
// examined set, OR its basename appears AND that basename is UNIQUE among the
|
|
310
|
+
// reviewable changed files. A basename shared by multiple reviewable files is
|
|
311
|
+
// AMBIGUOUS (e.g. src/a/index.js and test/b/index.js both basename "index.js"),
|
|
312
|
+
// so a bare-basename citation cannot prove which file was examined — for those we
|
|
313
|
+
// require the full-path match. This both tolerates differing path FORMS for an
|
|
314
|
+
// unambiguous file and prevents one ambiguous basename from "covering" several
|
|
315
|
+
// distinct files.
|
|
311
316
|
function coverageFailure(verdict, reviewablePaths) {
|
|
312
317
|
const coverage = verdict.coverage || {};
|
|
313
318
|
const examined = Array.isArray(coverage.files_examined) ? coverage.files_examined : [];
|
|
@@ -321,6 +326,13 @@ function coverageFailure(verdict, reviewablePaths) {
|
|
|
321
326
|
if (reviewablePaths.length > COVERAGE_FILE_CAP) {
|
|
322
327
|
return null;
|
|
323
328
|
}
|
|
329
|
+
// Count how often each basename occurs among the canonicalized reviewable
|
|
330
|
+
// changed files so we can tell unique basenames from ambiguous ones.
|
|
331
|
+
const baseCounts = new Map();
|
|
332
|
+
for (const path of reviewablePaths) {
|
|
333
|
+
const base = baseNameOf(canonicalizePath(path));
|
|
334
|
+
baseCounts.set(base, (baseCounts.get(base) || 0) + 1);
|
|
335
|
+
}
|
|
324
336
|
// Build canonical full-path and basename lookup sets from the citations.
|
|
325
337
|
const examinedFull = new Set();
|
|
326
338
|
const examinedBase = new Set();
|
|
@@ -333,7 +345,11 @@ function coverageFailure(verdict, reviewablePaths) {
|
|
|
333
345
|
for (const path of reviewablePaths) {
|
|
334
346
|
const canon = canonicalizePath(path);
|
|
335
347
|
const base = baseNameOf(canon);
|
|
336
|
-
|
|
348
|
+
// Full-path citation always counts. A basename citation only counts when that
|
|
349
|
+
// basename is UNIQUE among the reviewable changed files; an ambiguous basename
|
|
350
|
+
// (occurs >1) requires the full-path match.
|
|
351
|
+
if (examinedFull.has(canon)) continue;
|
|
352
|
+
if (baseCounts.get(base) === 1 && examinedBase.has(base)) continue;
|
|
337
353
|
return `missing_coverage:${canon}`;
|
|
338
354
|
}
|
|
339
355
|
return null;
|
package/src/core/load-config.js
CHANGED
|
@@ -96,10 +96,92 @@ export async function loadEffectiveConfig(cwd, io = {}) {
|
|
|
96
96
|
deepAssign(merged, sanitizeProjectConfig(userConfig));
|
|
97
97
|
deepAssign(merged, sanitizeProjectConfig(projectConfig));
|
|
98
98
|
|
|
99
|
+
// Reviewer trust floor: a PROJECT layer can never grant a reviewer trust nor
|
|
100
|
+
// supply the command/args/type of a custom reviewer. Those must come from
|
|
101
|
+
// USER-level config only (the threat model treats the repo's project config as
|
|
102
|
+
// untrusted). Applied AFTER the merge so it strips anything a project injected.
|
|
103
|
+
applyReviewerTrustFloor(merged, userConfig);
|
|
104
|
+
|
|
99
105
|
// Apply the user policy floor LAST so it can only tighten, never loosen.
|
|
100
106
|
return applyPolicyFloor(merged, userPolicyFloor);
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Enforce the reviewer trust floor on a fully-merged config using the RAW user
|
|
111
|
+
* config as the sole source of truth for trust and custom-reviewer definitions.
|
|
112
|
+
*
|
|
113
|
+
* Rules (all fail-closed; a project layer can only LOSE privileges here):
|
|
114
|
+
* - For every reviewer id: if merged.reviewers[id].trusted === true but the user
|
|
115
|
+
* config did NOT set trusted === true for that id, force trusted = false. A
|
|
116
|
+
* project config can never grant trust.
|
|
117
|
+
* - For any reviewer that is custom (merged type OR user-declared type is
|
|
118
|
+
* "custom"): take `type`, `command`, and `args` ONLY from the user config for
|
|
119
|
+
* that id, dropping any project-supplied values. If the user config did not
|
|
120
|
+
* define this custom reviewer, the result has no command and is rejected at
|
|
121
|
+
* runtime (fail closed).
|
|
122
|
+
* - opencode's `readOnlyConfig` is intentionally NOT touched here: opencode
|
|
123
|
+
* isolation is bound to the bundled read-only agent in enforced/strict, so a
|
|
124
|
+
* project-set readOnlyConfig is safe.
|
|
125
|
+
*
|
|
126
|
+
* Tolerant of missing/non-object reviewer maps and entries.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} merged - fully-merged effective config (mutated in place)
|
|
129
|
+
* @param {object} userConfig - raw user-level config (trusted source)
|
|
130
|
+
* @returns {object} merged
|
|
131
|
+
*/
|
|
132
|
+
function applyReviewerTrustFloor(merged, userConfig) {
|
|
133
|
+
const reviewers = merged.reviewers;
|
|
134
|
+
if (!reviewers || typeof reviewers !== "object" || Array.isArray(reviewers)) {
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
const userReviewers =
|
|
138
|
+
userConfig && typeof userConfig.reviewers === "object" && !Array.isArray(userConfig.reviewers)
|
|
139
|
+
? userConfig.reviewers
|
|
140
|
+
: {};
|
|
141
|
+
|
|
142
|
+
for (const id of Object.keys(reviewers)) {
|
|
143
|
+
const entry = reviewers[id];
|
|
144
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
145
|
+
const userEntry =
|
|
146
|
+
userReviewers[id] && typeof userReviewers[id] === "object" && !Array.isArray(userReviewers[id])
|
|
147
|
+
? userReviewers[id]
|
|
148
|
+
: null;
|
|
149
|
+
|
|
150
|
+
// Trust floor: only the user config can grant trust.
|
|
151
|
+
if (entry.trusted === true && userEntry?.trusted !== true) {
|
|
152
|
+
entry.trusted = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Custom-reviewer floor: command/args/type come from the user config only.
|
|
156
|
+
const isCustom = entry.type === "custom" || userEntry?.type === "custom";
|
|
157
|
+
if (isCustom) {
|
|
158
|
+
if (userEntry && userEntry.type === "custom") {
|
|
159
|
+
// Take the type/command/args from the trusted user config, dropping any
|
|
160
|
+
// project-supplied values.
|
|
161
|
+
entry.type = "custom";
|
|
162
|
+
if ("command" in userEntry) {
|
|
163
|
+
entry.command = userEntry.command;
|
|
164
|
+
} else {
|
|
165
|
+
delete entry.command;
|
|
166
|
+
}
|
|
167
|
+
if ("args" in userEntry) {
|
|
168
|
+
entry.args = structuredClone(userEntry.args);
|
|
169
|
+
} else {
|
|
170
|
+
delete entry.args;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// The user config did not define this custom reviewer. Strip any
|
|
174
|
+
// project-supplied command/args so it fails closed (no command -> rejected
|
|
175
|
+
// at runtime). Keep type so the custom adapter still recognizes the entry
|
|
176
|
+
// and refuses it via the missing-command / untrusted checks.
|
|
177
|
+
delete entry.command;
|
|
178
|
+
delete entry.args;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return merged;
|
|
183
|
+
}
|
|
184
|
+
|
|
103
185
|
/**
|
|
104
186
|
* Resolve the user-level state directory.
|
|
105
187
|
*
|
package/src/core/process.js
CHANGED
|
@@ -3,6 +3,36 @@ import path from "node:path";
|
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Read an env value by a case-insensitive key name.
|
|
8
|
+
*
|
|
9
|
+
* `process.env` is case-insensitive on win32, but a PLAIN-OBJECT env copy
|
|
10
|
+
* (e.g. `{ ...process.env }`, or an env coming from a native Windows
|
|
11
|
+
* cmd/powershell shell) may carry the key in a different case — e.g. `Path`
|
|
12
|
+
* instead of `PATH`. Reading `env.PATH` directly would then miss it and
|
|
13
|
+
* resolveExecutable would wrongly return null. This helper returns the value for
|
|
14
|
+
* the first key matching `name` case-insensitively, preferring an EXACT-case
|
|
15
|
+
* match (so behavior is unchanged when the uppercase key exists).
|
|
16
|
+
*
|
|
17
|
+
* @param {object} env - environment object
|
|
18
|
+
* @param {string} name - canonical key name (e.g. "PATH", "PATHEXT")
|
|
19
|
+
* @returns {string|undefined}
|
|
20
|
+
*/
|
|
21
|
+
function getEnvCaseInsensitive(env, name) {
|
|
22
|
+
if (!env || typeof env !== "object") return undefined;
|
|
23
|
+
// Prefer the exact key first to keep existing behavior identical.
|
|
24
|
+
if (Object.prototype.hasOwnProperty.call(env, name) && env[name] != null) {
|
|
25
|
+
return env[name];
|
|
26
|
+
}
|
|
27
|
+
const lower = name.toLowerCase();
|
|
28
|
+
for (const key of Object.keys(env)) {
|
|
29
|
+
if (key.toLowerCase() === lower && env[key] != null) {
|
|
30
|
+
return env[key];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
6
36
|
/**
|
|
7
37
|
* Resolve a command name or path to an absolute executable path.
|
|
8
38
|
* On Windows, walks PATHEXT extensions (e.g. .COM .EXE .BAT .CMD).
|
|
@@ -28,10 +58,14 @@ export async function resolveExecutable(command, env = process.env) {
|
|
|
28
58
|
return path.resolve(command);
|
|
29
59
|
}
|
|
30
60
|
|
|
31
|
-
|
|
61
|
+
// Read PATH / PATHEXT case-insensitively: a plain-object env copy (or a native
|
|
62
|
+
// Windows shell env) may carry these keys as `Path`/`PathExt`, etc.
|
|
63
|
+
const pathValue = getEnvCaseInsensitive(env, "PATH");
|
|
64
|
+
const pathExtValue = getEnvCaseInsensitive(env, "PATHEXT");
|
|
65
|
+
const pathEntries = String(pathValue || "").split(path.delimiter).filter(Boolean);
|
|
32
66
|
const extensions =
|
|
33
67
|
process.platform === "win32"
|
|
34
|
-
? String(
|
|
68
|
+
? String(pathExtValue || ".COM;.EXE;.BAT;.CMD").split(";")
|
|
35
69
|
: [""];
|
|
36
70
|
|
|
37
71
|
for (const dir of pathEntries) {
|
package/src/reviewers/_shared.js
CHANGED
|
@@ -127,11 +127,26 @@ export async function runWithTimeout(child, { timeoutMs, captureStderr = false }
|
|
|
127
127
|
: [collectOutput(child), waitForExit(child)];
|
|
128
128
|
|
|
129
129
|
const processPromise = Promise.all(collectors);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
// Capture the timer id so it can be cleared once the race settles. Without the
|
|
131
|
+
// clearTimeout below, a pending setTimeout keeps the Node event loop alive for
|
|
132
|
+
// up to timeoutMs after the process already completed, hanging the CLI/hook.
|
|
133
|
+
let timer;
|
|
134
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
135
|
+
timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs);
|
|
136
|
+
// unref() is a secondary measure so the timer alone never blocks exit; the
|
|
137
|
+
// clearTimeout below is the primary fix.
|
|
138
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
let raceResult;
|
|
142
|
+
try {
|
|
143
|
+
raceResult = await Promise.race([processPromise, timeoutPromise]);
|
|
144
|
+
} finally {
|
|
145
|
+
// Always clear the pending timeout timer — on BOTH the timeout branch and the
|
|
146
|
+
// normal completion branch — so the event loop is not held open.
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
}
|
|
133
149
|
|
|
134
|
-
const raceResult = await Promise.race([processPromise, timeoutPromise]);
|
|
135
150
|
if (raceResult === TIMEOUT_SENTINEL) {
|
|
136
151
|
forceKill(child);
|
|
137
152
|
return TIMEOUT_SENTINEL;
|