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,74 @@
1
+ // `adversarial-review check` — run the gate against the current workspace.
2
+ //
3
+ // Loads the effective config, captures a fresh baseline, runs evaluateGate, and
4
+ // reports the decision. `--json` emits the decision as machine-readable JSON.
5
+ // Exit code is 1 on block, 0 on allow. The whole evaluation is wrapped in the
6
+ // fail-closed catch (HARDENING #2): an unexpected throw with edit evidence
7
+ // blocks in enforced/strict rather than silently allowing.
8
+
9
+ import { evaluateGate } from "../core/gate.js";
10
+ import { captureBaseline } from "../core/diff.js";
11
+ import { loadEffectiveConfig, resolveStateDir } from "../core/load-config.js";
12
+ import { buildHostRouting } from "./host-map.js";
13
+ import { failClosedDecision } from "./fail-closed.js";
14
+ import { sessionStateKey } from "./hook.js";
15
+
16
+ /**
17
+ * @param {string[]} argv
18
+ * @param {object} io - { stdin, stdout, stderr, env, cwd }
19
+ */
20
+ export async function checkCommand(argv, io) {
21
+ const json = argv.includes("--json");
22
+ const host = parseHost(argv) || "cli";
23
+ const cwd = io.cwd;
24
+ const env = io.env || process.env;
25
+
26
+ const config = await loadEffectiveConfig(cwd, io);
27
+ const stateDir = resolveStateDir(env);
28
+ const { hostDescriptor, reviewerRunner } = buildHostRouting(host, config, env);
29
+
30
+ let decision;
31
+ try {
32
+ const baseline = await captureBaseline(cwd);
33
+ decision = await evaluateGate({
34
+ config,
35
+ cwd,
36
+ baseline,
37
+ transcript: "",
38
+ transcriptPath: "",
39
+ host: hostDescriptor,
40
+ reviewerRunner,
41
+ // Compose the synthetic session id with the canonical workspace root so the
42
+ // gate's block-counter/cache are keyed per-workspace, consistent with the
43
+ // hook's composite keying (distinct workspaces never share state).
44
+ sessionId: sessionStateKey(`check-${host}`, cwd),
45
+ stateDir,
46
+ });
47
+ } catch (err) {
48
+ // HARDENING #2: fail closed. `check` has no transcript, so edit evidence is
49
+ // whatever the live diff shows; failClosedDecision recomputes that.
50
+ decision = await failClosedDecision({ config, cwd, err, io });
51
+ }
52
+
53
+ if (json) {
54
+ io.stdout.write(`${JSON.stringify(decision)}\n`);
55
+ } else {
56
+ if (decision.action === "block") {
57
+ io.stderr.write(`BLOCK: ${decision.reason || "review required"}\n`);
58
+ } else if (decision.systemMessage) {
59
+ io.stdout.write(`${decision.systemMessage}\n`);
60
+ } else {
61
+ io.stdout.write(`allow: ${decision.reason || "ok"}\n`);
62
+ }
63
+ }
64
+
65
+ process.exitCode = decision.action === "block" ? 1 : 0;
66
+ return decision;
67
+ }
68
+
69
+ // Parse `--host <name>` if present.
70
+ function parseHost(argv) {
71
+ const i = argv.indexOf("--host");
72
+ if (i >= 0 && argv[i + 1]) return argv[i + 1];
73
+ return null;
74
+ }
@@ -0,0 +1,261 @@
1
+ // `adversarial-review doctor` — diagnostics for config, hosts, and reviewers.
2
+ //
3
+ // Reads (never writes) all config sources, checks reviewer availability, and
4
+ // reports a human-readable (or --json) summary with explicit WARNINGS for
5
+ // wrapper/advisory hosts.
6
+ //
7
+ // Exit codes:
8
+ // 0 - all checks passed (or completed with warnings)
9
+ // 1 - a fatal error was encountered (corrupt package.json, etc.)
10
+
11
+ import { readFile } from "node:fs/promises";
12
+ import { existsSync } from "node:fs";
13
+ import path from "node:path";
14
+ import os from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { HOSTS } from "../hosts/index.js";
18
+ import { createReviewer } from "../reviewers/index.js";
19
+ import { loadEffectiveConfig } from "../core/load-config.js";
20
+
21
+ // Paths relative to home / cwd.
22
+ const PROJECT_CONFIG_REL = path.join(".adversarial-review", "config.json");
23
+ const USER_CONFIG_REL = path.join(".adversarial-review", "config.json");
24
+ const USER_POLICY_REL = path.join(".adversarial-review", "policy.json");
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Resolve home from env, falling back to os.homedir(). Honors
31
+ * ADVERSARIAL_REVIEW_HOME so doctor reports the SAME user-level base that
32
+ * loadEffectiveConfig (the gate's loader) uses. */
33
+ function homeDir(env) {
34
+ if (env) {
35
+ const fromEnv = env.ADVERSARIAL_REVIEW_HOME || env.HOME || env.USERPROFILE;
36
+ if (fromEnv) return fromEnv;
37
+ }
38
+ return os.homedir();
39
+ }
40
+
41
+ /** Read package.json to get the package version. */
42
+ async function readPackageVersion() {
43
+ try {
44
+ // Walk up from this file to find package.json.
45
+ const thisFile = fileURLToPath(import.meta.url);
46
+ const pkgPath = path.join(path.dirname(thisFile), "..", "..", "package.json");
47
+ const raw = await readFile(pkgPath, "utf8");
48
+ const pkg = JSON.parse(raw);
49
+ return pkg.version || "unknown";
50
+ } catch {
51
+ return "unknown";
52
+ }
53
+ }
54
+
55
+ /** Check whether a reviewer is available; return { ok, resolvedPath, version, capabilities, reason }. */
56
+ async function checkReviewer(reviewerId, config, env) {
57
+ if (reviewerId === "none") {
58
+ return { ok: true, note: "native self-review (no external process)" };
59
+ }
60
+ try {
61
+ const adapter = createReviewer(reviewerId, config);
62
+ return adapter.verify(env);
63
+ } catch (err) {
64
+ return { ok: false, reason: err.message };
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Main doctor command
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * @param {string[]} argv
74
+ * @param {object} io - { stdin, stdout, stderr, env, cwd }
75
+ */
76
+ export async function doctorCommand(argv, io) {
77
+ const json = argv.includes("--json");
78
+ const cwd = io.cwd || process.cwd();
79
+ const env = io.env || process.env;
80
+ const home = homeDir(env);
81
+
82
+ // Read package version.
83
+ const version = await readPackageVersion();
84
+
85
+ // Locate config files.
86
+ const projectConfigPath = path.join(cwd, PROJECT_CONFIG_REL);
87
+ const userConfigPath = path.join(home, USER_CONFIG_REL);
88
+ const userPolicyPath = path.join(home, USER_POLICY_REL);
89
+
90
+ const projectConfigExists = existsSync(projectConfigPath);
91
+ const userConfigExists = existsSync(userConfigPath);
92
+ const userPolicyExists = existsSync(userPolicyPath);
93
+
94
+ // Validate project config (simple validity check).
95
+ let projectConfigValid = false;
96
+ if (projectConfigExists) {
97
+ try {
98
+ const raw = await readFile(projectConfigPath, "utf8");
99
+ const parsed = JSON.parse(raw);
100
+ projectConfigValid = parsed && typeof parsed === "object" && !Array.isArray(parsed);
101
+ } catch {
102
+ projectConfigValid = false;
103
+ }
104
+ }
105
+
106
+ // Effective config — use the SAME loader the gate uses so the report matches
107
+ // runtime reality: DEFAULT < user config (~/.adversarial-review/config.json) <
108
+ // project config, then the user policy floor applied on top. (The old doctor
109
+ // loader ignored the user-level config.json and under-reported configured
110
+ // hosts.)
111
+ const effectiveConfig = await loadEffectiveConfig(cwd, io);
112
+
113
+ // Enumerate configured hosts.
114
+ const configuredHostIds = Object.keys(effectiveConfig.hosts || {});
115
+ const hostReports = [];
116
+ const warnings = [];
117
+
118
+ for (const hostId of configuredHostIds) {
119
+ const hostInfo = HOSTS[hostId];
120
+ const hostConfig = effectiveConfig.hosts[hostId] || {};
121
+ const reviewerId = hostConfig.reviewer || "none";
122
+
123
+ // Check reviewer.
124
+ const reviewerResult = await checkReviewer(reviewerId, effectiveConfig, env);
125
+
126
+ const hostReport = {
127
+ id: hostId,
128
+ enforcement: hostInfo ? hostInfo.enforcement : "unknown",
129
+ capabilities: hostInfo || null,
130
+ reviewer: reviewerId,
131
+ reviewerAvailable: reviewerResult.ok,
132
+ reviewerPath: reviewerResult.resolvedPath || null,
133
+ reviewerVersion: reviewerResult.version || null,
134
+ reviewerCapabilities: reviewerResult.capabilities || null,
135
+ reviewerNote: reviewerResult.note || null,
136
+ reviewerReason: reviewerResult.reason || null,
137
+ };
138
+ hostReports.push(hostReport);
139
+
140
+ // Warn about wrapper/advisory hosts.
141
+ if (hostInfo && hostInfo.enforcement === "wrapper-enforced") {
142
+ warnings.push(
143
+ `WARNING: Host "${hostId}" is wrapper-enforced. Enforcement depends on the user ` +
144
+ `always invoking ${hostId} through \`adversarial-review run --host ${hostId}\`. ` +
145
+ `Bypassing the wrapper skips the review gate entirely. ` +
146
+ `This is NOT equivalent to native enforcement.`
147
+ );
148
+ }
149
+ if (!reviewerResult.ok && reviewerId !== "none") {
150
+ warnings.push(
151
+ `WARNING: Reviewer "${reviewerId}" for host "${hostId}" is unavailable: ` +
152
+ `${reviewerResult.reason || "unknown"}`
153
+ );
154
+ }
155
+ }
156
+
157
+ // Effective enforcement level.
158
+ const hasNativeHost = hostReports.some((h) => h.enforcement === "native-enforced");
159
+ const hasWrapperOnlyHosts =
160
+ hostReports.length > 0 && hostReports.every((h) => h.enforcement === "wrapper-enforced");
161
+ const effectiveEnforcement = hasNativeHost
162
+ ? "native-enforced"
163
+ : hasWrapperOnlyHosts
164
+ ? "wrapper-enforced (advisory)"
165
+ : "none";
166
+
167
+ // Build the report object.
168
+ const report = {
169
+ version,
170
+ projectConfigPath,
171
+ projectConfigExists,
172
+ projectConfigValid: projectConfigExists ? projectConfigValid : null,
173
+ userConfigPath,
174
+ userConfigExists,
175
+ userPolicyPath,
176
+ userPolicyExists,
177
+ privacyMode: effectiveConfig.privacy?.externalReview || "allow",
178
+ policyMode: effectiveConfig.policy?.mode || "enforced",
179
+ effectiveEnforcement,
180
+ allowAdvisoryHosts: effectiveConfig.policy?.allowAdvisoryHosts ?? false,
181
+ hosts: hostReports,
182
+ warnings,
183
+ };
184
+
185
+ if (json) {
186
+ io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
187
+ } else {
188
+ printHumanReport(report, io);
189
+ }
190
+
191
+ process.exitCode = 0;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Human-readable printer
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function printHumanReport(report, io) {
199
+ const w = (s) => io.stdout.write(s);
200
+
201
+ w(`adversarial-review v${report.version}\n`);
202
+ w(`\nProject config: ${report.projectConfigPath}\n`);
203
+ if (!report.projectConfigExists) {
204
+ w(` (not found — using defaults)\n`);
205
+ } else if (!report.projectConfigValid) {
206
+ w(` ERROR: file exists but is not valid JSON\n`);
207
+ } else {
208
+ w(` (valid)\n`);
209
+ }
210
+
211
+ w(`\nUser config (machine-wide defaults): ${report.userConfigPath}\n`);
212
+ if (!report.userConfigExists) {
213
+ w(` (not found — no machine-wide host/reviewer defaults)\n`);
214
+ } else {
215
+ w(` (present)\n`);
216
+ }
217
+
218
+ w(`\nUser policy floor: ${report.userPolicyPath}\n`);
219
+ if (!report.userPolicyExists) {
220
+ w(` (not found — no user-level floor enforced)\n`);
221
+ } else {
222
+ w(` (present)\n`);
223
+ }
224
+
225
+ w(`\nEffective policy:\n`);
226
+ w(` mode: ${report.policyMode}\n`);
227
+ w(` enforcement: ${report.effectiveEnforcement}\n`);
228
+ w(` allowAdvisoryHosts: ${report.allowAdvisoryHosts}\n`);
229
+ w(` privacyMode: ${report.privacyMode}\n`);
230
+
231
+ if (report.hosts.length === 0) {
232
+ w(`\nHosts: (none configured)\n`);
233
+ } else {
234
+ w(`\nHosts:\n`);
235
+ for (const h of report.hosts) {
236
+ w(` ${h.id} (${h.enforcement})\n`);
237
+ w(` reviewer: ${h.reviewer}\n`);
238
+ if (h.reviewer === "none") {
239
+ w(` reviewer status: native self-review\n`);
240
+ } else if (h.reviewerAvailable) {
241
+ w(` reviewer status: available\n`);
242
+ if (h.reviewerPath) w(` reviewer path: ${h.reviewerPath}\n`);
243
+ if (h.reviewerVersion) w(` reviewer version: ${h.reviewerVersion}\n`);
244
+ if (h.reviewerCapabilities) {
245
+ w(` reviewer capabilities: ${JSON.stringify(h.reviewerCapabilities)}\n`);
246
+ }
247
+ } else {
248
+ w(` reviewer status: UNAVAILABLE (${h.reviewerReason || "unknown"})\n`);
249
+ }
250
+ }
251
+ }
252
+
253
+ if (report.warnings.length > 0) {
254
+ w(`\nWarnings:\n`);
255
+ for (const warning of report.warnings) {
256
+ w(` ${warning}\n`);
257
+ }
258
+ } else {
259
+ w(`\nNo warnings.\n`);
260
+ }
261
+ }
@@ -0,0 +1,74 @@
1
+ // Shared fail-closed decision helper (HARDENING #2).
2
+ //
3
+ // `evaluateGate` may throw (e.g. a writeSessionState IO error, or an injected
4
+ // failure). When that happens the CLI entrypoints MUST NOT silently allow: if
5
+ // there is evidence of a real edit, they FAIL CLOSED — block in enforced/strict,
6
+ // and follow `onInternalError` in soft. The original error is surfaced on stderr.
7
+
8
+ import { block, advisory, allow } from "../core/gate.js";
9
+ import { buildReviewDiff } from "../core/diff.js";
10
+ import { internalErrorAction } from "../core/policy.js";
11
+ import { parseJsonl, scanKeys } from "../core/transcript.js";
12
+
13
+ /**
14
+ * Decide what to do after evaluateGate threw.
15
+ *
16
+ * @param {object} args
17
+ * @param {object} args.config
18
+ * @param {string} args.cwd
19
+ * @param {object} [args.baseline] - recorded baseline, if available.
20
+ * @param {string} [args.transcript] - transcript text, if available.
21
+ * @param {Error} args.err
22
+ * @param {object} args.io - { stderr }
23
+ * @returns {Promise<object>} decision
24
+ */
25
+ export async function failClosedDecision({ config, cwd, baseline, transcript, err, io }) {
26
+ if (io?.stderr) {
27
+ io.stderr.write(
28
+ `adversarial-review: gate evaluation failed (failing closed): ${err?.stack || err}\n`
29
+ );
30
+ }
31
+
32
+ const hasEvidence = await hasEditEvidence({ cwd, baseline, transcript });
33
+
34
+ // No evidence of a change: nothing to protect, allow.
35
+ if (!hasEvidence) {
36
+ return allow({ reason: "fail_open_no_evidence", internalError: String(err?.message || err) });
37
+ }
38
+
39
+ // Evidence present: follow onInternalError (allow only in soft when so set).
40
+ const action = internalErrorAction(config, true);
41
+ if (action === "allow") {
42
+ return advisory(
43
+ "Gate evaluation failed; allowed per soft onInternalError policy.",
44
+ { internalError: String(err?.message || err) }
45
+ );
46
+ }
47
+ return block(
48
+ "Adversarial review could not complete due to an internal error (fail-closed).",
49
+ { internalError: String(err?.message || err) }
50
+ );
51
+ }
52
+
53
+ // Best-effort edit-evidence detection: a non-empty review diff, OR edit/edited
54
+ // paths in the transcript. Tolerant of further failures (treats them as "no
55
+ // extra evidence" from that source).
56
+ async function hasEditEvidence({ cwd, baseline, transcript }) {
57
+ if (transcript) {
58
+ try {
59
+ const { lastEditKey, editedPaths } = scanKeys(parseJsonl(transcript));
60
+ if (lastEditKey > 0 || editedPaths.size > 0) return true;
61
+ } catch {
62
+ // Ignore transcript-scan failures.
63
+ }
64
+ }
65
+ if (baseline) {
66
+ try {
67
+ const diff = await buildReviewDiff(cwd, baseline);
68
+ if (diff && (diff.changedFiles?.length > 0 || diff.text)) return true;
69
+ } catch {
70
+ // Diff unbuildable from this baseline: fall through.
71
+ }
72
+ }
73
+ return false;
74
+ }
@@ -0,0 +1,267 @@
1
+ // `adversarial-review hook` — run as a native host lifecycle hook.
2
+ //
3
+ // Currently supports the Claude Code host (--host claude-code) with two events:
4
+ // --event session-start : capture + persist the workspace baseline (no output).
5
+ // --event stop (default) : run the gate and emit Claude Stop-hook JSON.
6
+ //
7
+ // The Stop event reads the host payload from stdin (JSON), loads the baseline
8
+ // recorded at SessionStart, builds the gate input, runs evaluateGate, and maps
9
+ // the decision to the Claude Stop hook protocol:
10
+ // block -> {"decision":"block","reason":"..."}
11
+ // advisory message -> {"systemMessage":"..."}
12
+ // silent allow -> no stdout output
13
+ //
14
+ // HARDENING #1: state lives at a user-level path (resolveStateDir), never
15
+ // repo-relative. HARDENING #2: gate evaluation is wrapped in a fail-closed
16
+ // try/catch that blocks on edit evidence in enforced/strict.
17
+
18
+ import { readFile } from "node:fs/promises";
19
+ import { realpathSync } from "node:fs";
20
+ import { resolve } from "node:path";
21
+ import { evaluateGate, block } from "../core/gate.js";
22
+ import { captureBaseline } from "../core/diff.js";
23
+ import { loadEffectiveConfig, resolveStateDir } from "../core/load-config.js";
24
+ import { readSessionState, writeSessionState } from "../core/state.js";
25
+ import { isStrict } from "../core/policy.js";
26
+ import { parseJsonl, scanKeys, isSubagentTranscript } from "../core/transcript.js";
27
+ import { buildHostRouting } from "./host-map.js";
28
+ import { failClosedDecision } from "./fail-closed.js";
29
+
30
+ /**
31
+ * @param {string[]} argv
32
+ * @param {object} io - { stdin, stdout, stderr, env, cwd }
33
+ */
34
+ export async function hookCommand(argv, io) {
35
+ const host = parseFlag(argv, "--host") || "claude-code";
36
+ const event = parseFlag(argv, "--event") || "stop";
37
+ const env = io.env || process.env;
38
+
39
+ const payload = await readStdinJson(io.stdin);
40
+ // The host payload carries the authoritative cwd; fall back to the process cwd.
41
+ const cwd = (payload && typeof payload.cwd === "string" && payload.cwd) || io.cwd;
42
+ const sessionId = (payload && payload.session_id) || "default";
43
+ const stateDir = resolveStateDir(env);
44
+ // Key state by session id AND canonical workspace root so distinct workspaces
45
+ // never share a baseline even when they share a session_id (cross-workspace
46
+ // baseline-collision bypass). This composite key is used both for direct
47
+ // readSessionState/writeSessionState here AND as the evaluateGate sessionId.
48
+ const stateKey = sessionStateKey(sessionId, cwd);
49
+
50
+ if (event === "session-start") {
51
+ return sessionStart({ cwd, stateKey, stateDir, io });
52
+ }
53
+ return stopEvent({ argv, host, env, payload, cwd, sessionId, stateKey, stateDir, io });
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // session-start: record the baseline. No blocking output.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ async function sessionStart({ cwd, stateKey, stateDir, io }) {
61
+ try {
62
+ const baseline = await captureBaseline(cwd);
63
+ const prev = await readSessionState(stateDir, stateKey);
64
+ await writeSessionState(stateDir, stateKey, {
65
+ ...prev,
66
+ baseline,
67
+ // Store the CANONICAL workspace root so the Stop event can validate that
68
+ // the baseline belongs to the workspace it is being evaluated against.
69
+ workspaceRoot: canonicalWorkspaceRoot(cwd),
70
+ updatedAt: Date.now(),
71
+ });
72
+ } catch (err) {
73
+ // SessionStart is best-effort; a failure here is surfaced but never blocks.
74
+ // The Stop event will detect the missing baseline and fail closed there.
75
+ io.stderr.write(`adversarial-review: session-start baseline capture failed: ${err.message}\n`);
76
+ }
77
+ // No stdout output for SessionStart.
78
+ process.exitCode = 0;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // stop: evaluate the gate and emit Claude Stop-hook JSON.
83
+ // ---------------------------------------------------------------------------
84
+
85
+ async function stopEvent({ host, env, payload, cwd, sessionId, stateKey, stateDir, io }) {
86
+ const config = await loadEffectiveConfig(cwd, io);
87
+ const enforced = config.policy.mode === "enforced" || isStrict(config);
88
+
89
+ const transcriptPath =
90
+ (payload && typeof payload.transcript_path === "string" && payload.transcript_path) || "";
91
+ const stopHookActive = Boolean(payload && payload.stop_hook_active);
92
+
93
+ // Subagent transcripts never trigger the gate (avoid serializing pipelines).
94
+ // The gate also checks this, but short-circuit here to avoid any state IO.
95
+ if (isSubagentTranscript(transcriptPath, sessionId)) {
96
+ return emit(null, io); // silent allow
97
+ }
98
+
99
+ // Read the transcript text (tolerant: unreadable -> "").
100
+ let transcript = "";
101
+ if (transcriptPath) {
102
+ transcript = await readFile(transcriptPath, "utf8").catch(() => "");
103
+ }
104
+
105
+ // Load the baseline recorded at SessionStart (keyed by session id AND
106
+ // canonical workspace root).
107
+ const state = await readSessionState(stateDir, stateKey);
108
+
109
+ // Defense-in-depth: even with the composite state key, never trust a baseline
110
+ // whose recorded workspaceRoot does not match the workspace we are evaluating.
111
+ // If it differs (or is somehow stale/mismatched), treat the baseline as ABSENT
112
+ // so we route into the missing-baseline path (block in enforced/strict; NOW
113
+ // baseline with a disclosed limitation in soft) rather than silently reusing a
114
+ // foreign repo's baseline.
115
+ const canonicalCwd = canonicalWorkspaceRoot(cwd);
116
+ const workspaceMatches =
117
+ !state.workspaceRoot || state.workspaceRoot === canonicalCwd;
118
+ let baseline = workspaceMatches ? state.baseline || null : null;
119
+
120
+ // Detect edit evidence so we can fail closed on a missing baseline.
121
+ const { lastEditKey, editedPaths } = scanKeys(parseJsonl(transcript));
122
+ const transcriptEditEvidence = lastEditKey > 0 || editedPaths.size > 0;
123
+
124
+ if (!baseline) {
125
+ if (transcriptEditEvidence && enforced) {
126
+ // HARDENING: edit evidence but NO recorded baseline in enforced/strict.
127
+ // We cannot trust an after-the-fact baseline to cover the change, so block
128
+ // and advise reinstalling the SessionStart hook.
129
+ const decision = block(
130
+ "Adversarial review could not verify this change: no SessionStart baseline was " +
131
+ "recorded for this session, so the full change scope is unknown. Reinstall the " +
132
+ "adversarial-review SessionStart hook (it records the baseline) and retry."
133
+ );
134
+ return emit(decision, io);
135
+ }
136
+ // Soft (or no edit evidence): fall back to a current-git/filesystem baseline.
137
+ // This is a disclosed limitation — only changes since NOW are visible.
138
+ baseline = await captureBaseline(cwd).catch(() => null);
139
+ }
140
+
141
+ const { hostDescriptor, reviewerRunner } = buildHostRouting(host, config, env);
142
+
143
+ let decision;
144
+ try {
145
+ decision = await evaluateGate({
146
+ config,
147
+ cwd,
148
+ baseline,
149
+ transcript,
150
+ transcriptPath,
151
+ host: hostDescriptor,
152
+ reviewerRunner,
153
+ // Use the composite key so the gate's block-counter/cache are also keyed
154
+ // per-workspace, consistent with the baseline state above.
155
+ sessionId: stateKey,
156
+ stateDir,
157
+ stopHookActive,
158
+ });
159
+ } catch (err) {
160
+ decision = await failClosedDecision({ config, cwd, baseline, transcript, err, io });
161
+ }
162
+
163
+ // When we fell back to a NOW baseline — either no recorded SessionStart
164
+ // baseline, or one rejected because it belongs to a different workspace — and
165
+ // there is edit evidence, disclose the limitation on whatever message we emit:
166
+ // only changes still present in the workspace were reviewable.
167
+ if ((!state.baseline || !workspaceMatches) && transcriptEditEvidence) {
168
+ const note =
169
+ " (Limitation: no SessionStart baseline was recorded; only changes still present in the " +
170
+ "workspace were reviewable. Reinstall the SessionStart hook for full coverage.)";
171
+ if (decision.systemMessage) {
172
+ decision = { ...decision, systemMessage: decision.systemMessage + note };
173
+ } else if (decision.action === "block" && decision.reason) {
174
+ decision = { ...decision, reason: decision.reason + note };
175
+ }
176
+ }
177
+
178
+ return emit(decision, io);
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Output mapping
183
+ // ---------------------------------------------------------------------------
184
+
185
+ // Emit Claude Stop-hook JSON for a decision. block -> decision/reason;
186
+ // advisory (allow + systemMessage) -> systemMessage; silent allow -> nothing.
187
+ function emit(decision, io) {
188
+ process.exitCode = 0;
189
+ if (!decision) return decision;
190
+ if (decision.action === "block") {
191
+ io.stdout.write(JSON.stringify({ decision: "block", reason: decision.reason || "" }));
192
+ return decision;
193
+ }
194
+ if (decision.systemMessage) {
195
+ io.stdout.write(JSON.stringify({ systemMessage: decision.systemMessage }));
196
+ return decision;
197
+ }
198
+ // Silent allow: no stdout output.
199
+ return decision;
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Workspace-scoped state keying
204
+ // ---------------------------------------------------------------------------
205
+
206
+ // Resolve a stable, canonical absolute path for a workspace root. realpathSync
207
+ // resolves symlinks so two different paths pointing at the same directory key
208
+ // the same state; if it throws (e.g. the path does not yet exist) we fall back
209
+ // to path.resolve so we still get an absolute, normalized key.
210
+ export function canonicalWorkspaceRoot(cwd) {
211
+ try {
212
+ return realpathSync(cwd);
213
+ } catch {
214
+ return resolve(cwd);
215
+ }
216
+ }
217
+
218
+ // Compose the session-state key from BOTH the host session id AND the canonical
219
+ // workspace root. Without the workspace component, two different workspaces that
220
+ // happen to share a session_id would collide on a single state file — letting
221
+ // repo A's baseline be used to evaluate repo B (a silent bypass). Including the
222
+ // canonical root makes the on-disk state file distinct per workspace, and keeps
223
+ // the gate's block-counter/cache consistent under the same composite key.
224
+ export function sessionStateKey(sessionId, cwd) {
225
+ return `${sessionId || ""} ${canonicalWorkspaceRoot(cwd)}`;
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Helpers
230
+ // ---------------------------------------------------------------------------
231
+
232
+ // Parse a `--flag <value>` pair from argv.
233
+ function parseFlag(argv, flag) {
234
+ const i = argv.indexOf(flag);
235
+ if (i >= 0 && argv[i + 1]) return argv[i + 1];
236
+ return null;
237
+ }
238
+
239
+ // Read all of stdin and parse it as JSON. Tolerant: empty/unreadable/malformed
240
+ // input yields `{}` so a malformed payload with no edit evidence fails open.
241
+ async function readStdinJson(stdin) {
242
+ const text = await readStream(stdin);
243
+ if (!text || !text.trim()) return {};
244
+ try {
245
+ const parsed = JSON.parse(text);
246
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
247
+ return {};
248
+ } catch {
249
+ return {};
250
+ }
251
+ }
252
+
253
+ // Drain a readable stream to a string. Accepts a Node Readable, an async
254
+ // iterable, or a plain string (tests may inject any of these).
255
+ async function readStream(stdin) {
256
+ if (stdin == null) return "";
257
+ if (typeof stdin === "string") return stdin;
258
+ const chunks = [];
259
+ try {
260
+ for await (const chunk of stdin) {
261
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
262
+ }
263
+ } catch {
264
+ return chunks.length ? Buffer.concat(chunks).toString("utf8") : "";
265
+ }
266
+ return Buffer.concat(chunks).toString("utf8");
267
+ }