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.
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +201 -0
- package/README.md +589 -0
- package/bin/adversarial-review.js +14 -0
- package/package.json +43 -0
- package/src/cli/check.js +74 -0
- package/src/cli/doctor.js +261 -0
- package/src/cli/fail-closed.js +74 -0
- package/src/cli/hook.js +267 -0
- package/src/cli/host-map.js +59 -0
- package/src/cli/install.js +503 -0
- package/src/cli/main.js +48 -0
- package/src/cli/run.js +178 -0
- package/src/core/classify.js +65 -0
- package/src/core/config.js +158 -0
- package/src/core/diff.js +443 -0
- package/src/core/gate.js +753 -0
- package/src/core/git.js +66 -0
- package/src/core/hash.js +27 -0
- package/src/core/load-config.js +133 -0
- package/src/core/paths.js +33 -0
- package/src/core/policy.js +77 -0
- package/src/core/process.js +158 -0
- package/src/core/secrets.js +46 -0
- package/src/core/state.js +107 -0
- package/src/core/transcript.js +381 -0
- package/src/core/verdict.js +67 -0
- package/src/hosts/claude-code.js +77 -0
- package/src/hosts/index.js +60 -0
- package/src/hosts/wrapper.js +37 -0
- package/src/integrations/claude-code/hooks.json +28 -0
- package/src/prompts/adversarial-review-orchestrator.md +219 -0
- package/src/prompts/external-brief.md +167 -0
- package/src/reviewers/codex.js +297 -0
- package/src/reviewers/custom.js +269 -0
- package/src/reviewers/index.js +121 -0
- package/src/reviewers/opencode.js +360 -0
|
@@ -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
|
+
}
|