adversarial-review-gate 2.0.2 → 2.1.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.
@@ -0,0 +1,204 @@
1
+ // `adversarial-review uninstall` — remove our hook entries and registry entry.
2
+ //
3
+ // Flags:
4
+ // --user / --global operate on user scope (<home>/.claude/settings.json
5
+ // and <home>/.adversarial-review/config.json) instead
6
+ // of the project (cwd) scope.
7
+ // --host <claude-code> restrict to a single native host (default: all
8
+ // native hosts; currently only claude-code).
9
+ // --remove-config also delete <scope>/.adversarial-review/config.json.
10
+ // --dry-run print what WOULD change; touch nothing.
11
+ //
12
+ // Behavior (tolerant + idempotent):
13
+ // - Remove ONLY our adversarial-review hook entries (dedupe key) from the
14
+ // relevant .claude/settings.json; preserve every other key/hook.
15
+ // - Optionally remove the scope's .adversarial-review/config.json.
16
+ // - Remove this scope's entry from the install registry
17
+ // (<home>/.adversarial-review/install.json).
18
+ // - PRINT what it KEPT (it never deletes the shared opencode agent by default).
19
+
20
+ import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
21
+ import { existsSync } from "node:fs";
22
+ import path from "node:path";
23
+
24
+ import {
25
+ removeClaudeCodeHooks,
26
+ detectClaudeCodeHooks,
27
+ claudeCodeSettingsPath,
28
+ } from "../hosts/claude-code.js";
29
+ import { resolveHomeDir } from "../core/load-config.js";
30
+
31
+ const PROJECT_CONFIG_REL = path.join(".adversarial-review", "config.json");
32
+ const USER_INSTALL_REL = path.join(".adversarial-review", "install.json");
33
+ const OPENCODE_AGENT_REL = path.join(
34
+ ".config",
35
+ "opencode",
36
+ "agent",
37
+ "adversarial-reviewer.md"
38
+ );
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Tolerantly read+parse a JSON object file; returns {} on any error. */
45
+ async function readJsonTolerant(filePath) {
46
+ try {
47
+ const raw = await readFile(filePath, "utf8");
48
+ const parsed = JSON.parse(raw);
49
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
50
+ return {};
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ /** Normalize a registry key (matches install.js): absolute + lowercased win32 drive. */
57
+ function normalizeRegistryKey(dir) {
58
+ let resolved = path.resolve(dir);
59
+ if (process.platform === "win32" && /^[a-zA-Z]:/.test(resolved)) {
60
+ resolved = resolved[0].toLowerCase() + resolved.slice(1);
61
+ }
62
+ return resolved;
63
+ }
64
+
65
+ /** Atomically write content (mode 0o644 — settings/config are team-shared). */
66
+ async function atomicWrite(filePath, content, mode = 0o644) {
67
+ const dir = path.dirname(filePath);
68
+ await mkdir(dir, { recursive: true });
69
+ const tmp = `${filePath}.tmp${Date.now()}`;
70
+ await writeFile(tmp, content, { encoding: "utf8", mode });
71
+ const { rename } = await import("node:fs/promises");
72
+ await rename(tmp, filePath);
73
+ }
74
+
75
+ /**
76
+ * Parse uninstall argv.
77
+ *
78
+ * @param {string[]} argv
79
+ * @returns {{ userScope: boolean, host: string|null, removeConfig: boolean, dryRun: boolean }}
80
+ */
81
+ function parseArgs(argv) {
82
+ let userScope = false;
83
+ let host = null;
84
+ let removeConfig = false;
85
+ let dryRun = false;
86
+
87
+ for (let i = 0; i < argv.length; i++) {
88
+ const arg = argv[i];
89
+ if (arg === "--user" || arg === "--global") {
90
+ userScope = true;
91
+ } else if (arg === "--dry-run") {
92
+ dryRun = true;
93
+ } else if (arg === "--remove-config") {
94
+ removeConfig = true;
95
+ } else if (arg === "--host" && argv[i + 1]) {
96
+ host = argv[i + 1].trim();
97
+ i++;
98
+ } else if (arg.startsWith("--host=")) {
99
+ host = arg.slice("--host=".length).trim();
100
+ }
101
+ }
102
+
103
+ return { userScope, host, removeConfig, dryRun };
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Main uninstall command
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * @param {string[]} argv
112
+ * @param {object} io - { stdin, stdout, stderr, env, cwd }
113
+ */
114
+ export async function uninstallCommand(argv, io) {
115
+ const cwd = io.cwd || process.cwd();
116
+ const env = io.env || process.env;
117
+ const home = resolveHomeDir(env);
118
+
119
+ const { userScope, host, removeConfig, dryRun } = parseArgs(argv);
120
+
121
+ // Unknown --host value is a usage error (only claude-code is a native host).
122
+ if (host && host !== "claude-code") {
123
+ io.stderr.write(
124
+ `adversarial-review uninstall: unsupported --host "${host}". ` +
125
+ `Only "claude-code" has native hooks to remove.\n`
126
+ );
127
+ process.exitCode = 2;
128
+ return;
129
+ }
130
+
131
+ const scopeBase = userScope ? home : cwd;
132
+ const w = (s) => io.stdout.write(s);
133
+
134
+ w(`adversarial-review uninstall${dryRun ? " --dry-run" : ""}: ${userScope ? "user" : "project"} scope\n`);
135
+
136
+ // --- 1. Remove our hooks from .claude/settings.json (claude-code) ---
137
+ const settingsPath = claudeCodeSettingsPath(scopeBase);
138
+ if (existsSync(settingsPath)) {
139
+ const existing = await readJsonTolerant(settingsPath);
140
+ const before = detectClaudeCodeHooks(existing);
141
+ if (before.sessionStart || before.stop) {
142
+ const cleaned = removeClaudeCodeHooks(existing);
143
+ if (dryRun) {
144
+ w(` WOULD remove adversarial-review hooks from ${settingsPath}\n`);
145
+ } else {
146
+ await atomicWrite(settingsPath, JSON.stringify(cleaned, null, 2), 0o644);
147
+ w(` Removed adversarial-review hooks from ${settingsPath}\n`);
148
+ }
149
+ } else {
150
+ w(` No adversarial-review hooks present in ${settingsPath} (nothing to remove).\n`);
151
+ }
152
+ } else {
153
+ w(` No .claude/settings.json at ${settingsPath} (nothing to remove).\n`);
154
+ }
155
+
156
+ // --- 2. Optionally remove the scope's config.json ---
157
+ const configPath = path.join(scopeBase, PROJECT_CONFIG_REL);
158
+ if (removeConfig) {
159
+ if (existsSync(configPath)) {
160
+ if (dryRun) {
161
+ w(` WOULD remove ${configPath}\n`);
162
+ } else {
163
+ await rm(configPath, { force: true });
164
+ w(` Removed ${configPath}\n`);
165
+ }
166
+ } else {
167
+ w(` No config at ${configPath} (nothing to remove).\n`);
168
+ }
169
+ } else if (existsSync(configPath)) {
170
+ w(` KEPT ${configPath} (pass --remove-config to delete it).\n`);
171
+ }
172
+
173
+ // --- 3. Remove this scope's entry from the install registry ---
174
+ const installRegistryPath = path.join(home, USER_INSTALL_REL);
175
+ const registryKey = normalizeRegistryKey(userScope ? home : cwd);
176
+ if (existsSync(installRegistryPath)) {
177
+ const registry = await readJsonTolerant(installRegistryPath);
178
+ if (Object.prototype.hasOwnProperty.call(registry, registryKey)) {
179
+ if (dryRun) {
180
+ w(` WOULD remove registry entry "${registryKey}" from ${installRegistryPath}\n`);
181
+ } else {
182
+ const { [registryKey]: _removed, ...rest } = registry;
183
+ await atomicWrite(installRegistryPath, JSON.stringify(rest, null, 2), 0o600);
184
+ w(` Removed registry entry "${registryKey}" from ${installRegistryPath}\n`);
185
+ }
186
+ } else {
187
+ w(` No registry entry for "${registryKey}" (nothing to remove).\n`);
188
+ }
189
+ } else {
190
+ w(` No install registry at ${installRegistryPath} (nothing to remove).\n`);
191
+ }
192
+
193
+ // --- 4. Report what we KEPT (the shared opencode agent is never deleted) ---
194
+ const opencodeAgentPath = path.join(home, OPENCODE_AGENT_REL);
195
+ if (existsSync(opencodeAgentPath)) {
196
+ w(
197
+ ` KEPT shared opencode read-only agent at ${opencodeAgentPath} ` +
198
+ `(shared machine-wide; delete it manually if no longer needed).\n`
199
+ );
200
+ }
201
+
202
+ w(`\nadversarial-review uninstall: complete.\n`);
203
+ process.exitCode = 0;
204
+ }
package/src/core/gate.js CHANGED
@@ -266,21 +266,86 @@ function cacheKeyFor(job, config) {
266
266
  // Coverage enforcement (deferred check 2)
267
267
  // ---------------------------------------------------------------------------
268
268
 
269
+ // Above this many reviewable changed files we stop requiring per-file coverage.
270
+ // A reviewer cannot reliably enumerate 40+ paths, so demanding an exact match of
271
+ // every one turns real PASSes into spurious BLOCKs. Over the cap we accept any
272
+ // non-empty coverage and record a coverage limitation instead of hard-failing.
273
+ const COVERAGE_FILE_CAP = 40;
274
+
275
+ // Canonicalize a path citation so coverage comparison is robust to the many FORMS
276
+ // a reviewer may use for the same file. Lower-risk normalizations only:
277
+ // - POSIX slashes (backslash -> slash);
278
+ // - trim surrounding whitespace;
279
+ // - strip a leading "a/" or "b/" git-diff prefix;
280
+ // - strip a leading "./";
281
+ // - strip a trailing ":<digits>" line-number suffix (e.g. "src/x.js:42").
282
+ // Returns "" for empty/non-string input.
283
+ function canonicalizePath(p) {
284
+ let s = String(p == null ? "" : p)
285
+ .replace(/\\/g, "/")
286
+ .trim();
287
+ if (!s) return "";
288
+ // Strip a trailing ":<digits>" line/column suffix before anything else.
289
+ s = s.replace(/:\d+$/, "");
290
+ // Strip git-diff prefixes "a/" or "b/".
291
+ if (s.startsWith("a/") || s.startsWith("b/")) s = s.slice(2);
292
+ // Strip a leading "./".
293
+ while (s.startsWith("./")) s = s.slice(2);
294
+ return s;
295
+ }
296
+
297
+ // The basename (last POSIX path segment) of an already-canonicalized path.
298
+ function baseNameOf(canonical) {
299
+ const idx = canonical.lastIndexOf("/");
300
+ return idx >= 0 ? canonical.slice(idx + 1) : canonical;
301
+ }
302
+
269
303
  // In enforced/strict, a pass must demonstrate coverage of every reviewable
270
304
  // changed file. Returns null when coverage is acceptable, or an error reason.
305
+ //
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.
271
311
  function coverageFailure(verdict, reviewablePaths) {
272
312
  const coverage = verdict.coverage || {};
273
313
  const examined = Array.isArray(coverage.files_examined) ? coverage.files_examined : [];
314
+ // Empty coverage on a non-empty reviewable diff is still an operational failure.
274
315
  if (reviewablePaths.length > 0 && examined.length === 0) {
275
316
  return "empty_coverage";
276
317
  }
277
- const examinedSet = new Set(examined.map((p) => String(p).replace(/\\/g, "/")));
318
+ // Per-file cap: with too many changed files, accept any non-empty coverage
319
+ // rather than demanding an exact per-file enumeration that no reviewer can
320
+ // reliably produce. The non-empty check above already handled the empty case.
321
+ if (reviewablePaths.length > COVERAGE_FILE_CAP) {
322
+ return null;
323
+ }
324
+ // Build canonical full-path and basename lookup sets from the citations.
325
+ const examinedFull = new Set();
326
+ const examinedBase = new Set();
327
+ for (const raw of examined) {
328
+ const canon = canonicalizePath(raw);
329
+ if (!canon) continue;
330
+ examinedFull.add(canon);
331
+ examinedBase.add(baseNameOf(canon));
332
+ }
278
333
  for (const path of reviewablePaths) {
279
- if (!examinedSet.has(path)) return `missing_coverage:${path}`;
334
+ const canon = canonicalizePath(path);
335
+ const base = baseNameOf(canon);
336
+ if (examinedFull.has(canon) || examinedBase.has(base)) continue;
337
+ return `missing_coverage:${canon}`;
280
338
  }
281
339
  return null;
282
340
  }
283
341
 
342
+ // True when the reviewable changed-file count exceeds the per-file cap, i.e. when
343
+ // coverageFailure relaxed the per-file requirement. Used to annotate the allow
344
+ // decision with a coverage limitation note (informational only).
345
+ function coverageLimited(reviewablePaths) {
346
+ return reviewablePaths.length > COVERAGE_FILE_CAP;
347
+ }
348
+
284
349
  // The enforced/strict DEFERRED CHECKS applied to any accepted pass, shared by
285
350
  // the external-reviewer path and the native self-review path:
286
351
  // (a) payload_hash must match the exact payload the gate built;
@@ -311,16 +376,14 @@ function deferredCheckFailure(verdict, job, reviewablePaths) {
311
376
  // (payload_hash match + non-empty coverage of every reviewable changed file).
312
377
  // For the debate level the verdict's level must also be "debate".
313
378
  //
314
- // A no-op Task carrying only the sentinel token cannot satisfy any of these, so
315
- // substring forgery is closed. The bare GATE_SENTINEL substring is never trusted
316
- // for acceptance; only the verdict block's own sentinel + a valid parse counts.
379
+ // Acceptance is decided solely by parseVerdict against `selfJob`: the verdict
380
+ // block's own sentinel (<<<ADVERSARIAL-REVIEW-VERDICT>>>) gates parsing, and a
381
+ // no-op Task that cannot produce a valid, matching verdict block is rejected.
317
382
  function selfReviewSatisfied(entries, lastEditKey, selfJob, reviewablePaths, enforced) {
318
383
  if (lastEditKey <= 0) return false;
319
384
  const outputs = collectReviewOutputs(entries, lastEditKey);
320
385
  for (const output of outputs) {
321
- // parseVerdict is the sole authority for acceptance. The verdict block's own
322
- // sentinel (<<<ADVERSARIAL-REVIEW-VERDICT>>>) gates parsing, so the bare
323
- // GATE_SENTINEL substring is never trusted on its own.
386
+ // parseVerdict is the sole authority for acceptance.
324
387
  const parsed = parseVerdict(output, selfJob);
325
388
  if (!parsed.ok) continue;
326
389
  const verdict = parsed.verdict;
@@ -398,8 +461,8 @@ export async function evaluateGate(input) {
398
461
  }
399
462
 
400
463
  const entries = parseJsonl(transcript || "");
401
- // Note: lastReviewKey/lastDebateKey (timestamp-based review detection) are no
402
- // longer used for acceptance native self-review is now verdict-based below.
464
+ // scanKeys reports only edit evidence; acceptance of a prior review is
465
+ // verdict-based (collectReviewOutputs + parseVerdict), handled below.
403
466
  const { lastEditKey, editedPaths } = scanKeys(entries);
404
467
 
405
468
  // (3) Build review scope from the authoritative filesystem/git diff.
@@ -492,7 +555,16 @@ export async function evaluateGate(input) {
492
555
  };
493
556
 
494
557
  if (selfReviewSatisfied(entries, lastEditKey, selfJob, reviewablePaths, enforced)) {
495
- return allow({ reason: "already_reviewed", level });
558
+ const passExtra = { reason: "already_reviewed", level };
559
+ // Mirror the external path: record a coverage limitation when the change has
560
+ // more reviewable files than the per-file coverage cap.
561
+ if (enforced && coverageLimited(reviewablePaths)) {
562
+ passExtra.coverageLimited = true;
563
+ passExtra.coverageNote =
564
+ `Coverage limitation: ${reviewablePaths.length} reviewable files exceed the ` +
565
+ `per-file coverage cap (${COVERAGE_FILE_CAP}); accepted on non-empty coverage.`;
566
+ }
567
+ return allow(passExtra);
496
568
  }
497
569
 
498
570
  // Emit the "self-review required" BLOCK for the current level. This is the
@@ -714,6 +786,14 @@ export async function evaluateGate(input) {
714
786
  await writeSessionState(stateDir, sessionId, { ...state, cache: nextCache });
715
787
  }
716
788
  const passExtra = { reason: "external_pass", level, cached: false };
789
+ // When the change has more reviewable files than the per-file coverage cap, the
790
+ // gate accepted non-empty (not exhaustive) coverage; record that limitation.
791
+ if (enforced && coverageLimited(reviewablePaths)) {
792
+ passExtra.coverageLimited = true;
793
+ passExtra.coverageNote =
794
+ `Coverage limitation: ${reviewablePaths.length} reviewable files exceed the ` +
795
+ `per-file coverage cap (${COVERAGE_FILE_CAP}); accepted on non-empty coverage.`;
796
+ }
717
797
  if (secretWarning) passExtra.systemMessage = secretWarning;
718
798
  return allow(passExtra);
719
799
  }
@@ -72,7 +72,7 @@ async function readJsonTolerant(file, io, label) {
72
72
  * @returns {Promise<object>} resolved config
73
73
  */
74
74
  export async function loadEffectiveConfig(cwd, io = {}) {
75
- const home = homeDir(io.env);
75
+ const home = resolveHomeDir(io.env);
76
76
  const userConfig = await readJsonTolerant(
77
77
  path.join(home, USER_CONFIG_REL),
78
78
  io,
@@ -115,16 +115,25 @@ export async function loadEffectiveConfig(cwd, io = {}) {
115
115
  export function resolveStateDir(env = process.env) {
116
116
  const override = env && env.ADVERSARIAL_REVIEW_STATE_DIR;
117
117
  if (override) return path.resolve(override);
118
- return path.join(homeDir(env), ".adversarial-review", "state");
118
+ return path.join(resolveHomeDir(env), ".adversarial-review", "state");
119
119
  }
120
120
 
121
- // Resolve the user's home directory, honoring an injected env so tests can
122
- // redirect the user-level base (config.json, policy.json, state dir) without
123
- // touching the real home dir. Priority:
124
- // 1. ADVERSARIAL_REVIEW_HOME dedicated override for the user-level base;
125
- // 2. HOME / USERPROFILE — standard OS home env vars;
126
- // 3. os.homedir() the real home dir.
127
- function homeDir(env) {
121
+ /**
122
+ * Resolve the user's home directory, honoring an injected env so tests can
123
+ * redirect the user-level base (config.json, policy.json, state dir, install
124
+ * registry, opencode agent) without touching the real home dir.
125
+ *
126
+ * This is the SINGLE shared resolver imported by install.js and doctor.js so
127
+ * the installer/doctor write/read the SAME user-level base the gate later uses.
128
+ * Priority:
129
+ * 1. ADVERSARIAL_REVIEW_HOME — dedicated override for the user-level base;
130
+ * 2. HOME / USERPROFILE — standard OS home env vars;
131
+ * 3. os.homedir() — the real home dir.
132
+ *
133
+ * @param {object} [env] - environment variables
134
+ * @returns {string} absolute home dir path
135
+ */
136
+ export function resolveHomeDir(env) {
128
137
  if (env) {
129
138
  const fromEnv = env.ADVERSARIAL_REVIEW_HOME || env.HOME || env.USERPROFILE;
130
139
  if (fromEnv) return fromEnv;
@@ -51,25 +51,6 @@ export async function resolveExecutable(command, env = process.env) {
51
51
  return null;
52
52
  }
53
53
 
54
- /**
55
- * Spawn a child process with shell:false to prevent shell-injection.
56
- * stdio defaults to ["ignore", "pipe", "pipe"].
57
- *
58
- * @param {string} command
59
- * @param {string[]} args
60
- * @param {object} options - { cwd, env, stdio }
61
- * @returns {import("node:child_process").ChildProcess}
62
- */
63
- export function spawnSafe(command, args, options = {}) {
64
- return spawn(command, args, {
65
- cwd: options.cwd,
66
- env: options.env,
67
- shell: false,
68
- stdio: options.stdio || ["ignore", "pipe", "pipe"],
69
- windowsHide: true,
70
- });
71
- }
72
-
73
54
  // Characters that cmd.exe treats as metacharacters when it re-parses the
74
55
  // trailing arguments of `cmd.exe /c <batch> <args...>`. An argument containing
75
56
  // any of these can break out of the intended command and execute attacker code,
@@ -2,17 +2,12 @@
2
2
  // Ports the Python functions from hooks/guard.py:
3
3
  // - ts_key -> tsKey
4
4
  // - iter_tool_uses -> iterToolUses (inline)
5
- // - completed_tool_ids -> completedToolIds (inline in scanKeys)
6
- // - scan_keys -> scanKeys
5
+ // - completed_tool_ids -> completedToolIds (inline in collectReviewOutputs)
6
+ // - scan_keys -> scanKeys (edit evidence only)
7
7
  // - is_subagent -> isSubagentTranscript
8
8
  // - last_user_text -> lastUserText
9
9
  // - wants_skip -> wantsSkip
10
10
 
11
- // Sentinels must match guard.py exactly so that review-task detection is
12
- // consistent between the Python and Node code paths.
13
- export const GATE_SENTINEL = "adversarial-review-gate";
14
- export const DEBATE_SENTINEL = "adversarial-debate-gate";
15
-
16
11
  const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
17
12
  const REVIEW_TOOLS = new Set(["Task", "Agent"]);
18
13
 
@@ -108,36 +103,21 @@ export function tsKey(s) {
108
103
  // ---- scanKeys ---------------------------------------------------------------
109
104
 
110
105
  /**
111
- * Scan JSONL transcript entries and return edit/review ordering keys plus the
112
- * set of file paths touched by edit tools.
106
+ * Scan JSONL transcript entries for edit evidence: the timestamp of the most
107
+ * recent edit tool-use and the set of file paths touched by edit tools.
108
+ *
109
+ * Edit tools: Edit, Write, MultiEdit, NotebookEdit.
113
110
  *
114
- * Mirrors Python's scan_keys() in guard.py exactly:
115
- * - Edit tools: Edit, Write, MultiEdit, NotebookEdit
116
- * - Review tools: Task, Agent (counted only when sentinel present AND
117
- * the call ran to completion, i.e. has a matching tool_result)
111
+ * Review-task detection is intentionally NOT done here. Acceptance of a prior
112
+ * review is verdict-based (collectReviewOutputs + parseVerdict in gate.js), so
113
+ * the old sentinel-matched lastReviewKey/lastDebateKey ordering keys have been
114
+ * removed no production caller consumed them.
118
115
  *
119
116
  * @param {object[]} entries Parsed transcript entries
120
- * @returns {{ lastEditKey: number, lastReviewKey: number, lastDebateKey: number, editedPaths: Set<string> }}
117
+ * @returns {{ lastEditKey: number, editedPaths: Set<string> }}
121
118
  */
122
119
  export function scanKeys(entries) {
123
- // Collect tool_use ids that have a corresponding tool_result (completed calls).
124
- const completed = new Set();
125
- for (const e of entries) {
126
- const msg = e?.message;
127
- if (!msg || typeof msg !== "object") continue;
128
- const content = msg.content;
129
- if (!Array.isArray(content)) continue;
130
- for (const blk of content) {
131
- if (blk && typeof blk === "object" && blk.type === "tool_result") {
132
- const tid = blk.tool_use_id;
133
- if (tid) completed.add(tid);
134
- }
135
- }
136
- }
137
-
138
120
  let lastEditKey = 0;
139
- let lastReviewKey = 0;
140
- let lastDebateKey = 0;
141
121
  const editedPaths = new Set();
142
122
 
143
123
  for (const e of entries) {
@@ -151,7 +131,6 @@ export function scanKeys(entries) {
151
131
  if (!blk || typeof blk !== "object" || blk.type !== "tool_use") continue;
152
132
  const name = blk.name || "";
153
133
  const inp = blk.input || {};
154
- const tid = blk.id || "";
155
134
 
156
135
  if (EDIT_TOOLS.has(name)) {
157
136
  if (key > lastEditKey) lastEditKey = key;
@@ -161,23 +140,11 @@ export function scanKeys(entries) {
161
140
  if (typeof p === "string" && p) editedPaths.add(p);
162
141
  }
163
142
  }
164
- } else if (REVIEW_TOOLS.has(name) && completed.has(tid)) {
165
- // Serialize the input to a lowercase string and check for sentinels.
166
- const blob =
167
- typeof inp === "object"
168
- ? JSON.stringify(inp).toLowerCase()
169
- : String(inp).toLowerCase();
170
- if (blob.includes(GATE_SENTINEL) && key > lastReviewKey) {
171
- lastReviewKey = key;
172
- }
173
- if (blob.includes(DEBATE_SENTINEL) && key > lastDebateKey) {
174
- lastDebateKey = key;
175
- }
176
143
  }
177
144
  }
178
145
  }
179
146
 
180
- return { lastEditKey, lastReviewKey, lastDebateKey, editedPaths };
147
+ return { lastEditKey, editedPaths };
181
148
  }
182
149
 
183
150
  // ---- collectReviewOutputs ---------------------------------------------------
@@ -1,7 +1,23 @@
1
1
  const START = "<<<ADVERSARIAL-REVIEW-VERDICT>>>";
2
2
  const END = "<<<END>>>";
3
+ const START_LOWER = START.toLowerCase();
3
4
  const MAX_OUTPUT_BYTES = 1024 * 1024;
4
5
 
6
+ // Count non-overlapping occurrences of `needle` in `haystack`. Used to detect
7
+ // multiple verdict-block markers case-insensitively (both operands lowercased).
8
+ function countOccurrences(haystack, needle) {
9
+ if (!needle) return 0;
10
+ let count = 0;
11
+ let from = 0;
12
+ for (;;) {
13
+ const idx = haystack.indexOf(needle, from);
14
+ if (idx < 0) break;
15
+ count += 1;
16
+ from = idx + needle.length;
17
+ }
18
+ return count;
19
+ }
20
+
5
21
  export function parseVerdict(output, job, options = {}) {
6
22
  // FIX 3: compute text once to avoid TOCTOU gap with non-idempotent toString objects
7
23
  const text = String(output);
@@ -13,15 +29,26 @@ export function parseVerdict(output, job, options = {}) {
13
29
  const start = text.indexOf(START);
14
30
  if (start < 0) return { ok: false, error: "missing_verdict_start" };
15
31
 
16
- // FIX 1: reject inputs that contain more than one verdict block (prompt-injection defence)
17
- if (text.indexOf(START) !== text.lastIndexOf(START)) {
32
+ // FIX 1: reject inputs that contain more than one verdict block (prompt-injection defence).
33
+ // Detect markers case-INSENSITIVELY: a SECOND verdict block authored with a
34
+ // different-case START marker (e.g. <<<adversarial-review-verdict>>>) must not
35
+ // slip past an exact-case indexOf/lastIndexOf check. A second verdict block
36
+ // always carries its own START sentinel, so 2+ START markers is the rejection
37
+ // signal. END markers are NOT counted here: the trailing-content relaxation
38
+ // (prose, and even a stray END, after the first block's END) must be preserved.
39
+ const lower = text.toLowerCase();
40
+ if (countOccurrences(lower, START_LOWER) > 1) {
18
41
  return { ok: false, error: "multiple_verdict_blocks" };
19
42
  }
20
43
 
21
44
  const end = text.indexOf(END, start + START.length);
22
45
  if (end < 0) return { ok: false, error: "missing_verdict_end" };
23
- const trailing = text.slice(end + END.length).trim();
24
- if (trailing) return { ok: false, error: "trailing_output_after_verdict" };
46
+ // Trailing content after the verdict block's <<<END>>> is intentionally ignored.
47
+ // Real LLM reviewers intermittently append a sign-off / extra prose after the
48
+ // verdict block; rejecting it made the gate unusable. Injection safety is preserved
49
+ // by the single-START requirement above: a second verdict block (the only injection
50
+ // vector that matters) is already rejected as multiple_verdict_blocks, so trailing
51
+ // non-START text is harmless.
25
52
  const body = text.slice(start + START.length, end).trim();
26
53
 
27
54
  // FIX 1 (defense-in-depth): reject nested sentinel tokens inside the extracted body