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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adversarial-review-gate",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "NodeJS multi-tool adversarial review gate for coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
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
- // and a changed file is considered covered if EITHER its full canonical path OR
309
- // its basename appears in the examined set. This prevents a real PASS from
310
- // BLOCKing merely because the reviewer cited a different path FORM.
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
- if (examinedFull.has(canon) || examinedBase.has(base)) continue;
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;
@@ -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
  *
@@ -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
- const pathEntries = String(env.PATH || "").split(path.delimiter).filter(Boolean);
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(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
68
+ ? String(pathExtValue || ".COM;.EXE;.BAT;.CMD").split(";")
35
69
  : [""];
36
70
 
37
71
  for (const dir of pathEntries) {
@@ -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
- const timeoutPromise = new Promise((resolve) =>
131
- setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs)
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;