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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +78 -0
- package/README.md +152 -45
- package/package.json +7 -2
- package/src/cli/doctor.js +108 -11
- package/src/cli/install.js +300 -35
- package/src/cli/main.js +6 -1
- package/src/cli/uninstall.js +204 -0
- package/src/core/gate.js +91 -11
- package/src/core/load-config.js +18 -9
- package/src/core/process.js +0 -19
- package/src/core/transcript.js +12 -45
- package/src/core/verdict.js +31 -4
- package/src/hosts/claude-code.js +221 -19
- package/src/hosts/index.js +2 -1
- package/src/integrations/opencode/adversarial-reviewer.agent.md +142 -0
- package/src/reviewers/_shared.js +146 -0
- package/src/reviewers/codex.js +59 -99
- package/src/reviewers/custom.js +58 -96
- package/src/reviewers/index.js +5 -3
- package/src/reviewers/opencode.js +126 -162
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
402
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/core/load-config.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
118
|
+
return path.join(resolveHomeDir(env), ".adversarial-review", "state");
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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;
|
package/src/core/process.js
CHANGED
|
@@ -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,
|
package/src/core/transcript.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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,
|
|
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,
|
|
147
|
+
return { lastEditKey, editedPaths };
|
|
181
148
|
}
|
|
182
149
|
|
|
183
150
|
// ---- collectReviewOutputs ---------------------------------------------------
|
package/src/core/verdict.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|