adversarial-review-gate 2.0.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,381 @@
1
+ // Transcript parser and skip detection.
2
+ // Ports the Python functions from hooks/guard.py:
3
+ // - ts_key -> tsKey
4
+ // - iter_tool_uses -> iterToolUses (inline)
5
+ // - completed_tool_ids -> completedToolIds (inline in scanKeys)
6
+ // - scan_keys -> scanKeys
7
+ // - is_subagent -> isSubagentTranscript
8
+ // - last_user_text -> lastUserText
9
+ // - wants_skip -> wantsSkip
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
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
17
+ const REVIEW_TOOLS = new Set(["Task", "Agent"]);
18
+
19
+ // ---- Escape-hatch detection (ported from guard.py) --------------------------
20
+ // Tight allow-list of fillers between "skip" and the object; Vietnamese variants
21
+ // are included (bỏ qua review, khỏi review, etc.). A negation guard rejects
22
+ // "don't skip the review" etc. A trailing-NOUN guard rejects "skip the debate
23
+ // club" etc. — only sentence-end, punctuation, newline, or a function-word
24
+ // continuation are accepted.
25
+ // Note: the `g` flag is required so that re.exec() advances lastIndex on each
26
+ // call inside wantsSkip's while loop. Without `g`, exec() returns the same
27
+ // match every time, causing an infinite loop.
28
+ const SKIP_RE = new RegExp(
29
+ "(?:" +
30
+ // English: skip[ping] <optional fillers> <object>
31
+ "\\bskip(?:ping)?\\s+(?:this\\s+|that\\s+|the\\s+|an?\\s+|adversarial\\s+|code\\s+|multi-agent\\s+)*(?:review|debate|panel)" +
32
+ // Vietnamese: bỏ [qua] <object> or khỏi <object>
33
+ "|\\bb[oỏ]\\s+(?:qua\\s+)?(?:review|debate)" +
34
+ "|\\bkh[oỏ]i\\s+(?:review|debate)" +
35
+ ")\\b" +
36
+ // Boundary: sentence-end / punctuation, OR a function-word / adverb continuation.
37
+ "(?=\\s*(?:[.,!?;:)\\]\\n]|$)" +
38
+ "|\\s+(?:please|thanks?|now|today|just|then|so|and|or|since|because" +
39
+ "|for|this|that|too|also|entirely|altogether|finally|asap|ok(?:ay)?)\\b)",
40
+ "gi",
41
+ );
42
+
43
+ const NEG_RE = new RegExp(
44
+ "\\b(?:not|never|dont|doesnt|didnt|wont|cant|cannot|isnt|arent|aint|nor|without" +
45
+ "|refrain|avoid|forbid|forbidden|prohibit|prohibited|decline|refuse|refusing" +
46
+ "|hold\\s+off|rather\\s+not" +
47
+ "|no\\s+(?:need|reason|way|account|means|event|circumstances)" +
48
+ "|kh[ôo]ng|đừng|dừng|chớ|chẳng|chưa|chả)\\b",
49
+ "i",
50
+ );
51
+
52
+ // Matches individual words (letters only, no digits, no underscore).
53
+ const WORD_RE = /[^\W\d_]+/gu;
54
+ const NEG_WINDOW_WORDS = 8;
55
+
56
+ // "? no" / "? nope" trailing negation after the skip phrase.
57
+ const TRAILING_NO_RE = /\s*\?\s*(?:absolutely\s+|certainly\s+|definitely\s+)?(?:no|nope|nah|not|never)\b/i;
58
+
59
+ // Self-disarm defense (layer 2): the gate's own block message contains the phrase
60
+ // "skip the review"; never let the gate's echo be read as a user skip request.
61
+ const HOOK_ECHO_RE =
62
+ /stop hook feedback|has NOT passed an adversarial review|this gate (?:stands down|recognises)/i;
63
+
64
+ // ---- parseJsonl -------------------------------------------------------------
65
+
66
+ /**
67
+ * Split a JSONL text into an array of parsed objects.
68
+ * Lines that fail to parse are silently dropped (tolerant mode).
69
+ *
70
+ * @param {string} text
71
+ * @returns {object[]}
72
+ */
73
+ export function parseJsonl(text) {
74
+ return String(text)
75
+ .split(/\r?\n/)
76
+ .filter(Boolean)
77
+ .flatMap((line) => {
78
+ try {
79
+ return [JSON.parse(line)];
80
+ } catch {
81
+ return [];
82
+ }
83
+ });
84
+ }
85
+
86
+ // ---- tsKey ------------------------------------------------------------------
87
+
88
+ /**
89
+ * Convert an ISO-8601 timestamp string to a comparable epoch float (seconds).
90
+ * Handles the trailing-Z form and any UTC-offset form supported by Date.parse.
91
+ * Returns 0 for any unparseable or missing value — treated as "oldest".
92
+ *
93
+ * Mirrors Python's ts_key() in guard.py (datetime.fromisoformat + .timestamp()).
94
+ *
95
+ * @param {unknown} s
96
+ * @returns {number}
97
+ */
98
+ export function tsKey(s) {
99
+ if (typeof s !== "string" || !s) return 0;
100
+ const t = s.trim();
101
+ // Date.parse handles ISO-8601 with Z or offsets natively in Node.js / V8.
102
+ // fromisoformat in Python 3.11+ also handles these forms — equivalent.
103
+ const ms = Date.parse(t);
104
+ if (Number.isNaN(ms)) return 0;
105
+ return ms / 1000; // epoch seconds, matching Python .timestamp()
106
+ }
107
+
108
+ // ---- scanKeys ---------------------------------------------------------------
109
+
110
+ /**
111
+ * Scan JSONL transcript entries and return edit/review ordering keys plus the
112
+ * set of file paths touched by edit tools.
113
+ *
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)
118
+ *
119
+ * @param {object[]} entries Parsed transcript entries
120
+ * @returns {{ lastEditKey: number, lastReviewKey: number, lastDebateKey: number, editedPaths: Set<string> }}
121
+ */
122
+ 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
+ let lastEditKey = 0;
139
+ let lastReviewKey = 0;
140
+ let lastDebateKey = 0;
141
+ const editedPaths = new Set();
142
+
143
+ for (const e of entries) {
144
+ const key = tsKey(e?.timestamp);
145
+ const msg = e?.message;
146
+ if (!msg || typeof msg !== "object") continue;
147
+ const content = msg.content;
148
+ if (!Array.isArray(content)) continue;
149
+
150
+ for (const blk of content) {
151
+ if (!blk || typeof blk !== "object" || blk.type !== "tool_use") continue;
152
+ const name = blk.name || "";
153
+ const inp = blk.input || {};
154
+ const tid = blk.id || "";
155
+
156
+ if (EDIT_TOOLS.has(name)) {
157
+ if (key > lastEditKey) lastEditKey = key;
158
+ if (inp && typeof inp === "object") {
159
+ for (const k of ["file_path", "notebook_path"]) {
160
+ const p = inp[k];
161
+ if (typeof p === "string" && p) editedPaths.add(p);
162
+ }
163
+ }
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
+ }
177
+ }
178
+ }
179
+
180
+ return { lastEditKey, lastReviewKey, lastDebateKey, editedPaths };
181
+ }
182
+
183
+ // ---- collectReviewOutputs ---------------------------------------------------
184
+
185
+ /**
186
+ * Extract the plain-text payload of a `tool_result` content block.
187
+ *
188
+ * Anthropic transcripts encode tool_result content either as a bare string or
189
+ * as an array of blocks (commonly `{ type: "text", text: "..." }`). We
190
+ * concatenate all text-bearing parts so a verdict block embedded anywhere in
191
+ * the subagent's final output can be parsed.
192
+ *
193
+ * @param {unknown} content
194
+ * @returns {string}
195
+ */
196
+ function toolResultText(content) {
197
+ if (typeof content === "string") return content;
198
+ if (Array.isArray(content)) {
199
+ const parts = [];
200
+ for (const blk of content) {
201
+ if (typeof blk === "string") {
202
+ parts.push(blk);
203
+ } else if (blk && typeof blk === "object") {
204
+ if (typeof blk.text === "string") parts.push(blk.text);
205
+ else if (typeof blk.content === "string") parts.push(blk.content);
206
+ }
207
+ }
208
+ return parts.join("\n");
209
+ }
210
+ return "";
211
+ }
212
+
213
+ /**
214
+ * Collect the final OUTPUT text of every review Task/Agent tool-use that
215
+ * COMPLETED (has a matching tool_result) strictly after `afterKey`.
216
+ *
217
+ * Unlike `scanKeys`, this does NOT gate on a sentinel substring — acceptance is
218
+ * decided by the caller via `parseVerdict`. A sentinel may still be used by the
219
+ * caller as a cheap pre-filter, but it must never be the basis for acceptance.
220
+ *
221
+ * Ordering: the review's timestamp (when its tool_use was issued) must be
222
+ * strictly greater than `afterKey` (the last edit). This mirrors the
223
+ * "completed after the last edit" requirement so a stale, pre-edit review can
224
+ * never satisfy the current change.
225
+ *
226
+ * @param {object[]} entries Parsed transcript entries
227
+ * @param {number} afterKey Epoch-seconds lower bound (typically lastEditKey)
228
+ * @returns {string[]} output text strings, in transcript order
229
+ */
230
+ export function collectReviewOutputs(entries, afterKey = 0) {
231
+ // Map tool_use_id -> concatenated tool_result output text (completed calls).
232
+ const outputs = new Map();
233
+ for (const e of entries) {
234
+ const msg = e?.message;
235
+ if (!msg || typeof msg !== "object") continue;
236
+ const content = msg.content;
237
+ if (!Array.isArray(content)) continue;
238
+ for (const blk of content) {
239
+ if (blk && typeof blk === "object" && blk.type === "tool_result") {
240
+ const tid = blk.tool_use_id;
241
+ if (tid) outputs.set(tid, toolResultText(blk.content));
242
+ }
243
+ }
244
+ }
245
+
246
+ const results = [];
247
+ for (const e of entries) {
248
+ const key = tsKey(e?.timestamp);
249
+ const msg = e?.message;
250
+ if (!msg || typeof msg !== "object") continue;
251
+ const content = msg.content;
252
+ if (!Array.isArray(content)) continue;
253
+ for (const blk of content) {
254
+ if (!blk || typeof blk !== "object" || blk.type !== "tool_use") continue;
255
+ const name = blk.name || "";
256
+ const tid = blk.id || "";
257
+ if (!REVIEW_TOOLS.has(name)) continue;
258
+ if (!outputs.has(tid)) continue; // not completed
259
+ if (key <= afterKey) continue; // not strictly after the last edit
260
+ results.push(outputs.get(tid));
261
+ }
262
+ }
263
+ return results;
264
+ }
265
+
266
+ // ---- isSubagentTranscript ---------------------------------------------------
267
+
268
+ /**
269
+ * Return true when the transcript belongs to a workflow-spawned subagent that
270
+ * should NOT be gated (to avoid serializing parallel pipelines).
271
+ *
272
+ * Mirrors the Python check in guard.py main():
273
+ * session_id.startswith("g-") OR
274
+ * "/subagents/" in tp OR
275
+ * basename(tp).startswith("agent-")
276
+ *
277
+ * Works on Windows paths (backslashes are normalised first).
278
+ *
279
+ * @param {string} transcriptPath
280
+ * @param {string} [sessionId=""]
281
+ * @returns {boolean}
282
+ */
283
+ export function isSubagentTranscript(transcriptPath, sessionId = "") {
284
+ const normalized = String(transcriptPath || "").replace(/\\/g, "/");
285
+ const base = normalized.split("/").at(-1) || "";
286
+ return (
287
+ String(sessionId).startsWith("g-") ||
288
+ normalized.includes("/subagents/") ||
289
+ base.startsWith("agent-")
290
+ );
291
+ }
292
+
293
+ // ---- lastUserText -----------------------------------------------------------
294
+
295
+ /**
296
+ * Return the text of the most recent GENUINE human prompt from the transcript.
297
+ *
298
+ * Excludes:
299
+ * - assistant turns
300
+ * - isMeta / synthetic injections (skill notices, system reminders, hook feedback)
301
+ * - entries whose content consists entirely of tool_result blocks
302
+ *
303
+ * This is layer 1 of the self-disarm defense; HOOK_ECHO_RE is layer 2.
304
+ *
305
+ * Mirrors Python's last_user_text() in guard.py.
306
+ *
307
+ * @param {object[]} entries
308
+ * @returns {string}
309
+ */
310
+ export function lastUserText(entries) {
311
+ for (let i = entries.length - 1; i >= 0; i--) {
312
+ const e = entries[i];
313
+ if (e?.type !== "user" || e?.isMeta) continue;
314
+ const msg = e?.message;
315
+ if (!msg || typeof msg !== "object") continue;
316
+ const content = msg.content;
317
+ if (typeof content === "string") {
318
+ if (content.trim()) return content;
319
+ continue;
320
+ }
321
+ if (Array.isArray(content)) {
322
+ // Skip entries that are purely tool_result blocks (no genuine user text).
323
+ if (content.some((b) => b && typeof b === "object" && b.type === "tool_result")) {
324
+ continue;
325
+ }
326
+ const texts = content
327
+ .filter((b) => b && typeof b === "object" && b.type === "text")
328
+ .map((b) => b.text || "");
329
+ const joined = texts.filter(Boolean).join(" ").trim();
330
+ if (joined) return joined;
331
+ }
332
+ }
333
+ return "";
334
+ }
335
+
336
+ // ---- wantsSkip --------------------------------------------------------------
337
+
338
+ /**
339
+ * Return true only if the text is a GENUINE request to skip the review.
340
+ *
341
+ * A skip phrase preceded (within NEG_WINDOW_WORDS words) by a negation cue
342
+ * does NOT count. A trailing-noun that extends the object phrase does NOT count.
343
+ * The gate's own echoed block reason does NOT count (HOOK_ECHO_RE defense).
344
+ *
345
+ * Errs toward review when ambiguous: a false-negative costs only an extra review,
346
+ * whereas a wrong match would silently disarm a safety gate.
347
+ *
348
+ * Mirrors Python's wants_skip() in guard.py.
349
+ *
350
+ * @param {string} text
351
+ * @returns {boolean}
352
+ */
353
+ export function wantsSkip(text) {
354
+ if (!text || HOOK_ECHO_RE.test(text)) return false; // layer-2 self-disarm defense
355
+
356
+ // Normalize BOTH curly apostrophes (U+2018 left, U+2019 right) to a straight
357
+ // apostrophe. Mirrors Python: text.replace("‘","’").replace("’","’").
358
+ // The original regex had all three chars as U+2019 (a no-op); this is the fix.
359
+ const norm = text.replace(/[‘’]/g, "'");
360
+
361
+ // Create a fresh regex instance so that lastIndex starts at 0 and we can
362
+ // iterate over all matches. (SKIP_RE has the `g` flag, so exec() advances
363
+ // lastIndex; using a fresh instance avoids cross-call state leakage.)
364
+ const re = new RegExp(SKIP_RE.source, SKIP_RE.flags);
365
+ let match;
366
+ while ((match = re.exec(norm)) !== null) {
367
+ // Check for trailing negation ("? no", "? never", etc.)
368
+ if (TRAILING_NO_RE.test(norm.slice(match.index + match[0].length))) continue;
369
+
370
+ // Extract the window of words before the match for negation detection.
371
+ // Strip STRAIGHT apostrophe (U+0027) so contractions collapse:
372
+ // "don’t" → "dont", which NEG_RE matches. The curly apostrophes were
373
+ // already normalized to straight on line 276, so this single replace
374
+ // covers all variants. Mirrors Python: pre.replace("’", "").
375
+ const pre = norm.slice(0, match.index).replace(/'/g, "");
376
+ const words = [...pre.toLowerCase().matchAll(WORD_RE)].map((m) => m[0]);
377
+ const window = words.slice(-NEG_WINDOW_WORDS).join(" ");
378
+ if (!NEG_RE.test(window)) return true;
379
+ }
380
+ return false;
381
+ }
@@ -0,0 +1,67 @@
1
+ const START = "<<<ADVERSARIAL-REVIEW-VERDICT>>>";
2
+ const END = "<<<END>>>";
3
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
4
+
5
+ export function parseVerdict(output, job, options = {}) {
6
+ // FIX 3: compute text once to avoid TOCTOU gap with non-idempotent toString objects
7
+ const text = String(output);
8
+
9
+ if (Buffer.byteLength(text, "utf8") > (options.maxBytes || MAX_OUTPUT_BYTES)) {
10
+ return { ok: false, error: "verdict_output_too_large" };
11
+ }
12
+
13
+ const start = text.indexOf(START);
14
+ if (start < 0) return { ok: false, error: "missing_verdict_start" };
15
+
16
+ // FIX 1: reject inputs that contain more than one verdict block (prompt-injection defence)
17
+ if (text.indexOf(START) !== text.lastIndexOf(START)) {
18
+ return { ok: false, error: "multiple_verdict_blocks" };
19
+ }
20
+
21
+ const end = text.indexOf(END, start + START.length);
22
+ 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" };
25
+ const body = text.slice(start + START.length, end).trim();
26
+
27
+ // FIX 1 (defense-in-depth): reject nested sentinel tokens inside the extracted body
28
+ if (body.includes(START) || body.includes(END)) {
29
+ return { ok: false, error: "nested_verdict_block" };
30
+ }
31
+
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(body);
35
+ } catch {
36
+ return { ok: false, error: "invalid_verdict_json" };
37
+ }
38
+ return validateVerdict(parsed, job);
39
+ }
40
+
41
+ export function validateVerdict(parsed, job) {
42
+ if (!parsed || typeof parsed !== "object") return { ok: false, error: "verdict_not_object" };
43
+ if (parsed.job_id !== job.jobId) return { ok: false, error: "job_id_mismatch" };
44
+ if (parsed.diff_hash !== job.diffHash) return { ok: false, error: "diff_hash_mismatch" };
45
+ if (parsed.reviewer !== job.reviewer) return { ok: false, error: "reviewer_mismatch" };
46
+ if (parsed.level !== job.level) return { ok: false, error: "level_mismatch" };
47
+ if (!["pass", "fail"].includes(parsed.verdict)) return { ok: false, error: "invalid_verdict_value" };
48
+ if (!Array.isArray(parsed.findings)) parsed.findings = [];
49
+ if (!parsed.coverage || typeof parsed.coverage !== "object") {
50
+ return { ok: false, error: "missing_coverage" };
51
+ }
52
+ const required = job.requiredDimensions || [];
53
+ const dimensions = parsed.dimensions || {};
54
+ for (const dimension of required) {
55
+ if (!(dimension in dimensions)) return { ok: false, error: `missing_dimension:${dimension}` };
56
+ }
57
+ // FIX 2: require severity to be a string so array/object/number values cannot
58
+ // bypass the forced-fail by accidentally matching via type coercion
59
+ const forcedFail = parsed.findings.some(
60
+ (finding) =>
61
+ finding &&
62
+ typeof finding.severity === "string" &&
63
+ ["Critical", "Important"].includes(finding.severity)
64
+ );
65
+ const verdict = forcedFail ? "fail" : parsed.verdict;
66
+ return { ok: true, verdict: { ...parsed, verdict } };
67
+ }
@@ -0,0 +1,77 @@
1
+ // Claude Code native host integration module.
2
+ //
3
+ // Returns the planned writes needed to enable the Claude Code SessionStart and
4
+ // Stop hooks. Exposes a planning function rather than writing files directly so
5
+ // the `install` command can operate in dry-run mode without touching the disk.
6
+ //
7
+ // Hook target: bin/adversarial-review.js hook --host claude-code --event <event>
8
+ //
9
+ // Claude Code hooks.json location (per-project): <cwd>/.claude/settings.json
10
+ // (hooks are embedded in the settings file as a "hooks" key) or a standalone
11
+ // <cwd>/.claude/hooks.json depending on the Claude Code version. We write the
12
+ // settings.json variant as that is the current standard and supported by
13
+ // Task 12; Task 12 will refine the exact template.
14
+
15
+ import path from "node:path";
16
+
17
+ /**
18
+ * Build the hook configuration object for Claude Code.
19
+ *
20
+ * @param {object} options
21
+ * @param {string} options.binPath - absolute path to the adversarial-review binary
22
+ * @returns {object} hook config JSON object
23
+ */
24
+ function buildHookConfig(binPath) {
25
+ const bin = binPath || "npx adversarial-review";
26
+ return {
27
+ hooks: {
28
+ SessionStart: [
29
+ {
30
+ hooks: [
31
+ {
32
+ type: "command",
33
+ command: `${bin} hook --host claude-code --event session-start`,
34
+ },
35
+ ],
36
+ },
37
+ ],
38
+ Stop: [
39
+ {
40
+ hooks: [
41
+ {
42
+ type: "command",
43
+ command: `${bin} hook --host claude-code --event stop`,
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Return the list of planned writes to enable the Claude Code native hooks.
54
+ *
55
+ * Each entry describes a file that the installer would write:
56
+ * { path: <absolute path>, content: <JSON string>, note: <human note> }
57
+ *
58
+ * This function never writes anything — it is intentionally pure so callers
59
+ * (including dry-run mode) can inspect planned writes before committing.
60
+ *
61
+ * @param {object} options
62
+ * @param {string} options.cwd - project root (where .claude/ lives)
63
+ * @param {string} [options.binPath] - resolved path to adversarial-review binary
64
+ * @returns {Array<{path: string, content: string, note: string}>}
65
+ */
66
+ export function plannedClaudeCodeWrites({ cwd, binPath }) {
67
+ const hookConfig = buildHookConfig(binPath);
68
+ const settingsPath = path.join(cwd, ".claude", "settings.json");
69
+
70
+ return [
71
+ {
72
+ path: settingsPath,
73
+ content: JSON.stringify(hookConfig, null, 2),
74
+ note: "Claude Code native hooks (SessionStart + Stop) — native-enforced",
75
+ },
76
+ ];
77
+ }
@@ -0,0 +1,60 @@
1
+ // Host capability registry.
2
+ //
3
+ // Each entry describes how a host integrates with the gate:
4
+ // enforcement: "native-enforced" - the host has a native Stop hook
5
+ // that the gate can attach to;
6
+ // "wrapper-enforced" - the gate is invoked via a
7
+ // wrapper command (npx adversarial-
8
+ // review run --host <h> -- <cmd>).
9
+ // supportsBaseline: true when the host exposes SessionStart / session-
10
+ // open lifecycle hooks where we can capture a baseline.
11
+ // supportsSelfReview: true when the host's own agent can act as reviewer
12
+ // (native self-review).
13
+ // supportsNativeBlock: true when the gate can hard-block the host from
14
+ // completing an action via a native protocol return
15
+ // (e.g. Claude Code Stop hook {"decision":"block"}).
16
+ // supportsExternalReview: true when an external reviewer process (codex,
17
+ // opencode, custom) can be used for this host.
18
+
19
+ export const HOSTS = {
20
+ "claude-code": {
21
+ id: "claude-code",
22
+ enforcement: "native-enforced",
23
+ supportsBaseline: false,
24
+ supportsSelfReview: true,
25
+ supportsNativeBlock: true,
26
+ supportsExternalReview: true,
27
+ },
28
+ "codex": {
29
+ id: "codex",
30
+ enforcement: "wrapper-enforced",
31
+ supportsBaseline: true,
32
+ supportsSelfReview: true,
33
+ supportsNativeBlock: false,
34
+ supportsExternalReview: true,
35
+ },
36
+ "opencode": {
37
+ id: "opencode",
38
+ enforcement: "wrapper-enforced",
39
+ supportsBaseline: true,
40
+ supportsSelfReview: true,
41
+ supportsNativeBlock: false,
42
+ supportsExternalReview: true,
43
+ },
44
+ "github-copilot-cli": {
45
+ id: "github-copilot-cli",
46
+ enforcement: "wrapper-enforced",
47
+ supportsBaseline: true,
48
+ supportsSelfReview: false,
49
+ supportsNativeBlock: false,
50
+ supportsExternalReview: false,
51
+ },
52
+ "antigravity": {
53
+ id: "antigravity",
54
+ enforcement: "wrapper-enforced",
55
+ supportsBaseline: true,
56
+ supportsSelfReview: false,
57
+ supportsNativeBlock: false,
58
+ supportsExternalReview: false,
59
+ },
60
+ };
@@ -0,0 +1,37 @@
1
+ // Wrapper host integration module.
2
+ //
3
+ // Wrapper-enforced hosts cannot install native hooks; enforcement depends on
4
+ // the user invoking the tool via an `adversarial-review run` wrapper command.
5
+ // This module returns printable instructions (no file writes) that the
6
+ // installer presents to the user.
7
+
8
+ /**
9
+ * Return the wrapper invocation string and residual-risk note for a host.
10
+ *
11
+ * No file writes occur — wrapper hosts require the user to change their own
12
+ * launch command. The returned object is printable by the installer.
13
+ *
14
+ * @param {object} options
15
+ * @param {string} options.host - host id (e.g. "codex", "opencode")
16
+ * @param {string} [options.reviewer] - reviewer id (may be "none")
17
+ * @param {string} [options.binPath] - resolved path to adversarial-review binary
18
+ * @returns {{ host: string, wrapperCommand: string, enforcement: string, residualRisk: string }}
19
+ */
20
+ export function wrapperInstructions({ host, reviewer, binPath }) {
21
+ const bin = binPath || "npx adversarial-review";
22
+ const reviewerNote = reviewer && reviewer !== "none" ? ` (reviewer: ${reviewer})` : "";
23
+
24
+ // Build a representative wrapper command. The user substitutes their actual
25
+ // subcommand in place of the placeholder.
26
+ const wrapperCommand = `${bin} run --host ${host} -- ${host} <your-command>`;
27
+
28
+ return {
29
+ host,
30
+ wrapperCommand,
31
+ enforcement: "wrapper-enforced",
32
+ residualRisk:
33
+ `Wrapper enforcement depends on the user always invoking ${host} through ` +
34
+ `adversarial-review run. Bypassing the wrapper skips the review gate entirely. ` +
35
+ `Native enforcement is not available for this host${reviewerNote}.`,
36
+ };
37
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/adversarial-review.js\" hook --host claude-code --event session-start",
9
+ "statusMessage": "Adversarial review baseline",
10
+ "timeout": 60
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "Stop": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/bin/adversarial-review.js\" hook --host claude-code --event stop",
21
+ "statusMessage": "Adversarial review gate",
22
+ "timeout": 300
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
28
+ }