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/src/cli/run.js ADDED
@@ -0,0 +1,178 @@
1
+ // `adversarial-review run` — wrap a host tool command and gate after it exits.
2
+ //
3
+ // adversarial-review run --host <host> -- <command> [args...]
4
+ //
5
+ // Captures a baseline BEFORE running, spawns the command with inherited stdio,
6
+ // waits for it to exit, waits a quiescence interval, then recaptures the review
7
+ // scope. If files are STILL changing across two snapshots (the diff hash keeps
8
+ // moving), in enforced/strict we BLOCK (files are still being written). Otherwise
9
+ // we run evaluateGate. The original command's exit code is returned ONLY when the
10
+ // gate allows; on block we exit non-zero (2) and print the reason to stderr.
11
+ //
12
+ // HARDENING #1: user-level stateDir. HARDENING #2: fail-closed try/catch.
13
+
14
+ import { evaluateGate } from "../core/gate.js";
15
+ import { captureBaseline, buildReviewDiff } from "../core/diff.js";
16
+ import { loadEffectiveConfig, resolveStateDir } from "../core/load-config.js";
17
+ import { isStrict } from "../core/policy.js";
18
+ import { resolveExecutable, spawnResolved } from "../core/process.js";
19
+ import { buildHostRouting } from "./host-map.js";
20
+ import { failClosedDecision } from "./fail-closed.js";
21
+ import { sessionStateKey } from "./hook.js";
22
+
23
+ const DEFAULT_QUIESCENCE_MS = 750;
24
+ const BLOCK_EXIT_CODE = 2;
25
+
26
+ /**
27
+ * @param {string[]} argv
28
+ * @param {object} io - { stdin, stdout, stderr, env, cwd }
29
+ */
30
+ export async function runCommand(argv, io) {
31
+ const { host, command } = parseArgs(argv);
32
+ const env = io.env || process.env;
33
+ const cwd = io.cwd;
34
+
35
+ if (command.length === 0) {
36
+ io.stderr.write("usage: adversarial-review run --host <host> -- <command> [args...]\n");
37
+ process.exitCode = 2;
38
+ return;
39
+ }
40
+
41
+ const config = await loadEffectiveConfig(cwd, io);
42
+ const enforced = config.policy.mode === "enforced" || isStrict(config);
43
+ const stateDir = resolveStateDir(env);
44
+ const quiescenceMs = config.runtime?.quiescenceMs ?? DEFAULT_QUIESCENCE_MS;
45
+
46
+ // Capture baseline BEFORE running so post-run diff reflects only the wrapped
47
+ // command's changes.
48
+ const baseline = await captureBaseline(cwd);
49
+
50
+ // Run the wrapped command with inherited stdio.
51
+ const exitCode = await runWrapped(command, { cwd, env, io });
52
+
53
+ // Wait for filesystem quiescence, then confirm the workspace has settled.
54
+ await sleep(quiescenceMs);
55
+ const stillChanging = await stillChangingScope(cwd, baseline, quiescenceMs);
56
+ if (stillChanging && enforced) {
57
+ io.stderr.write(
58
+ "BLOCK: workspace is still being written after the command exited; cannot review a " +
59
+ "moving target. Re-run once the tool has finished.\n"
60
+ );
61
+ process.exitCode = BLOCK_EXIT_CODE;
62
+ return { action: "block", reason: "files_still_changing" };
63
+ }
64
+
65
+ const { hostDescriptor, reviewerRunner } = buildHostRouting(host, config, env);
66
+
67
+ let decision;
68
+ try {
69
+ decision = await evaluateGate({
70
+ config,
71
+ cwd,
72
+ baseline,
73
+ transcript: "",
74
+ transcriptPath: "",
75
+ host: hostDescriptor,
76
+ reviewerRunner,
77
+ // Compose the synthetic session id with the canonical workspace root so the
78
+ // gate's block-counter/cache are keyed per-workspace, consistent with the
79
+ // hook's composite keying (distinct workspaces never share state).
80
+ sessionId: sessionStateKey(`run-${host}`, cwd),
81
+ stateDir,
82
+ });
83
+ } catch (err) {
84
+ decision = await failClosedDecision({ config, cwd, baseline, err, io });
85
+ }
86
+
87
+ if (decision.action === "block") {
88
+ io.stderr.write(`BLOCK: ${decision.reason || "review required"}\n`);
89
+ process.exitCode = BLOCK_EXIT_CODE;
90
+ return decision;
91
+ }
92
+
93
+ if (decision.systemMessage) {
94
+ io.stderr.write(`${decision.systemMessage}\n`);
95
+ }
96
+ // Gate allowed: surface the wrapped command's own exit code.
97
+ process.exitCode = exitCode;
98
+ return decision;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Wrapped command execution
103
+ // ---------------------------------------------------------------------------
104
+
105
+ // Resolve and spawn the wrapped command with inherited stdio; resolve with its
106
+ // exit code. A missing executable resolves to 127 (shell "command not found").
107
+ async function runWrapped(command, { cwd, env, io }) {
108
+ const [exe, ...args] = command;
109
+ const resolved = await resolveExecutable(exe, env);
110
+ if (!resolved) {
111
+ io.stderr.write(`adversarial-review run: command not found: ${exe}\n`);
112
+ return 127;
113
+ }
114
+ return new Promise((resolve) => {
115
+ let child;
116
+ try {
117
+ child = spawnResolved(resolved, args, { cwd, env, stdio: "inherit" });
118
+ } catch (err) {
119
+ io.stderr.write(`adversarial-review run: failed to spawn ${exe}: ${err.message}\n`);
120
+ resolve(126);
121
+ return;
122
+ }
123
+ child.on("error", (err) => {
124
+ io.stderr.write(`adversarial-review run: ${exe} error: ${err.message}\n`);
125
+ resolve(127);
126
+ });
127
+ child.on("close", (code) => resolve(code == null ? 0 : code));
128
+ });
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Quiescence detection
133
+ // ---------------------------------------------------------------------------
134
+
135
+ // Take two review-scope snapshots `quiescenceMs` apart. If the diff hash changes
136
+ // between them, files are still being written (not quiescent). Tolerant: a
137
+ // build failure returns false (do not wedge on a diff error — the gate's own
138
+ // internal-error handling covers an unbuildable diff).
139
+ async function stillChangingScope(cwd, baseline, quiescenceMs) {
140
+ let first;
141
+ try {
142
+ first = await buildReviewDiff(cwd, baseline);
143
+ } catch {
144
+ return false;
145
+ }
146
+ await sleep(quiescenceMs);
147
+ let second;
148
+ try {
149
+ second = await buildReviewDiff(cwd, baseline);
150
+ } catch {
151
+ return false;
152
+ }
153
+ return first.diffHash !== second.diffHash;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Arg parsing
158
+ // ---------------------------------------------------------------------------
159
+
160
+ // Parse `--host <host> -- <command...>`. Everything after the first `--` is the
161
+ // command. `--host` defaults to "wrapper".
162
+ function parseArgs(argv) {
163
+ let host = "wrapper";
164
+ const sep = argv.indexOf("--");
165
+ const head = sep >= 0 ? argv.slice(0, sep) : argv;
166
+ const command = sep >= 0 ? argv.slice(sep + 1) : [];
167
+ for (let i = 0; i < head.length; i += 1) {
168
+ if (head[i] === "--host" && head[i + 1]) {
169
+ host = head[i + 1];
170
+ i += 1;
171
+ }
172
+ }
173
+ return { host, command };
174
+ }
175
+
176
+ function sleep(ms) {
177
+ return new Promise((resolve) => setTimeout(resolve, ms));
178
+ }
@@ -0,0 +1,65 @@
1
+ // File classification: determines whether a changed file is reviewable,
2
+ // sensitive, or docs-only. Used by the gate to decide which files to send
3
+ // to external reviewers and which require extra scrutiny.
4
+
5
+ const CODE_EXTS = new Set([
6
+ ".py", ".pyi", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".go", ".rs",
7
+ ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", ".java", ".kt", ".kts", ".rb",
8
+ ".php", ".swift", ".scala", ".sh", ".bash", ".zsh", ".sql", ".vue",
9
+ ".svelte", ".dart", ".lua", ".ex", ".exs", ".clj", ".erl", ".pl", ".r",
10
+ ".jl", ".groovy", ".gradle", ".tf", ".yaml", ".yml", ".json", ".toml",
11
+ // Windows scripts
12
+ ".bat", ".cmd", ".ps1", ".psm1",
13
+ // Terraform/HCL variable and config files
14
+ ".tfvars", ".hcl",
15
+ // Jupyter notebooks — committed executable code
16
+ ".ipynb",
17
+ ]);
18
+
19
+ const DOC_EXTS = new Set([".md", ".txt", ".rst", ".adoc"]);
20
+
21
+ // Matches sensitive path segments. Includes SSH/TLS key file names and
22
+ // extensions so that private-key files are classified sensitive=true.
23
+ const SENSITIVE_RE = /auth|login|password|passwd|secret|credential|token|crypto|payment|billing|migration|\.env|security|permission|access[_-]?control|deploy|infra|terraform|k8s|kube|dockerfile|workflow|github\/workflows|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.pem|\.pfx|\.p12|\.key|\.keystore|\.jks/i;
24
+
25
+ // Exact lowercase base-name matches for well-known build/manifest/lockfiles.
26
+ // IMPORTANT: `base` is derived from the lowercased file path, so every entry
27
+ // here MUST be lowercase — a capitalised entry would be dead code.
28
+ const REVIEWABLE_NAMES = new Set([
29
+ "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
30
+ // Dockerfile — lowercase because `base` is always lowercased
31
+ "dockerfile", "docker-compose.yml", "compose.yml", "tsconfig.json",
32
+ // Build automation
33
+ "makefile", "gnumakefile", "justfile", "rakefile",
34
+ // Ruby / Bundler
35
+ "gemfile", "gemfile.lock",
36
+ // Rust / Cargo
37
+ "cargo.toml", "cargo.lock",
38
+ // Go modules
39
+ "go.mod", "go.sum",
40
+ // Python packaging
41
+ "poetry.lock", "pyproject.toml", "requirements.txt",
42
+ // PHP / Composer
43
+ "composer.json", "composer.lock",
44
+ ]);
45
+
46
+ /**
47
+ * Classify a file path into reviewable/sensitive/docsOnly categories.
48
+ *
49
+ * @param {string} filePath - The path to classify (may use backslashes on Windows).
50
+ * @param {object} [config={}] - Optional merged config with sensitivity overrides.
51
+ * @returns {{ reviewable: boolean, sensitive: boolean, docsOnly: boolean, ext: string, base: string }}
52
+ */
53
+ export function classifyPath(filePath, config = {}) {
54
+ // Normalize Windows backslashes so SENSITIVE_RE and path logic work uniformly.
55
+ const normalized = filePath.replace(/\\/g, "/");
56
+ const lower = normalized.toLowerCase();
57
+ const base = lower.split("/").at(-1);
58
+ const ext = base.includes(".") ? `.${base.split(".").at(-1)}` : "";
59
+ const extraExts = new Set(config.sensitivity?.extraCodeExts || []);
60
+ const extraSensitive = (config.sensitivity?.extraSensitive || []).map(String);
61
+ const sensitive = SENSITIVE_RE.test(normalized) || extraSensitive.some((part) => normalized.includes(part));
62
+ const reviewable = sensitive || CODE_EXTS.has(ext) || extraExts.has(ext) || REVIEWABLE_NAMES.has(base);
63
+ const docsOnly = DOC_EXTS.has(ext) && !sensitive && !reviewable;
64
+ return { reviewable, sensitive, docsOnly, ext, base };
65
+ }
@@ -0,0 +1,158 @@
1
+ // Default configuration for adversarial-review.
2
+ // All sub-objects are frozen shallowly; consumers receive a deep clone via mergeConfig.
3
+
4
+ export const DEFAULT_CONFIG = Object.freeze({
5
+ version: 2,
6
+ policy: {
7
+ mode: "enforced",
8
+ reviewScope: "all-code",
9
+ onReviewerError: "block",
10
+ onInternalError: "block",
11
+ onBlockCap: "block",
12
+ allowSkip: false,
13
+ allowAdvisoryHosts: false,
14
+ },
15
+ thresholds: {
16
+ bigDiffLines: 80,
17
+ bigFileCount: 5,
18
+ debateDiffLines: 250,
19
+ debateFileCount: 12,
20
+ debateOnSensitive: true,
21
+ },
22
+ sensitivity: {
23
+ extraSensitive: [],
24
+ extraCodeExts: [],
25
+ },
26
+ runtime: {
27
+ blockCap: 4,
28
+ stateTtlDays: 14,
29
+ timeoutSec: 180,
30
+ baselineRef: "auto",
31
+ },
32
+ privacy: {
33
+ externalReview: "allow",
34
+ secretScan: "block-external",
35
+ tempFileMode: "0600",
36
+ },
37
+ hosts: {},
38
+ reviewers: {},
39
+ });
40
+
41
+ // Known top-level config keys; unknown keys are stripped by sanitizeProjectConfig.
42
+ const TOP_LEVEL_KEYS = new Set([
43
+ "version",
44
+ "policy",
45
+ "thresholds",
46
+ "sensitivity",
47
+ "runtime",
48
+ "privacy",
49
+ "hosts",
50
+ "reviewers",
51
+ ]);
52
+
53
+ /**
54
+ * Strip unknown top-level keys from a raw project config object.
55
+ * Does not validate nested keys — that is intentionally left loose so
56
+ * future sub-keys added to DEFAULT_CONFIG work without updating this list.
57
+ *
58
+ * @param {object} raw
59
+ * @returns {object}
60
+ */
61
+ export function sanitizeProjectConfig(raw) {
62
+ const clean = {};
63
+ for (const [key, value] of Object.entries(raw || {})) {
64
+ if (TOP_LEVEL_KEYS.has(key)) clean[key] = value;
65
+ }
66
+ return clean;
67
+ }
68
+
69
+ /**
70
+ * Recursively assign source properties onto target.
71
+ * Arrays are treated as scalars (replaced, not merged).
72
+ *
73
+ * @param {object} target
74
+ * @param {object} source
75
+ * @returns {object} target
76
+ */
77
+ export function deepAssign(target, source) {
78
+ for (const [key, value] of Object.entries(source || {})) {
79
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
80
+ if (value && typeof value === "object" && !Array.isArray(value)) {
81
+ if (!target[key] || typeof target[key] !== "object" || Array.isArray(target[key])) {
82
+ target[key] = {};
83
+ }
84
+ deepAssign(target[key], value);
85
+ } else {
86
+ target[key] = value;
87
+ }
88
+ }
89
+ return target;
90
+ }
91
+
92
+ // Ordering for mode strictness — higher index means stricter.
93
+ const MODE_RANK = new Map([
94
+ ["soft", 0],
95
+ ["enforced", 1],
96
+ ["strict-ci", 2],
97
+ ]);
98
+
99
+ /**
100
+ * Apply a user-level policy floor to a fully-merged config object so that
101
+ * a project config can never loosen what the user has set as a minimum.
102
+ *
103
+ * Floor rules (all one-directional — can only tighten, never loosen):
104
+ * - mode: ratchets to whichever rank is higher
105
+ * - allowSkip / allowAdvisoryHosts: floor=false forces false
106
+ * - onReviewerError / onInternalError / onBlockCap: floor="block" forces "block"
107
+ * - reviewScope: floor="all-code" forces "all-code"
108
+ * - privacy.externalReview: floor="deny" forces "deny"
109
+ * - privacy.secretScan: floor="block-all" forces "block-all"
110
+ *
111
+ * @param {object} config - already deep-cloned merged config (mutated in place)
112
+ * @param {object} floor - user policy floor (may have .policy sub-object or be flat)
113
+ * @returns {object} config
114
+ */
115
+ export function applyPolicyFloor(config, floor = {}) {
116
+ const floorPolicy = floor.policy || floor;
117
+
118
+ if (floorPolicy.mode && MODE_RANK.has(floorPolicy.mode)) {
119
+ const currentRank = MODE_RANK.get(config.policy.mode) ?? 1;
120
+ const floorRank = MODE_RANK.get(floorPolicy.mode);
121
+ if (currentRank < floorRank) config.policy.mode = floorPolicy.mode;
122
+ }
123
+
124
+ for (const key of ["allowSkip", "allowAdvisoryHosts"]) {
125
+ if (floorPolicy[key] === false) config.policy[key] = false;
126
+ }
127
+
128
+ for (const key of ["onReviewerError", "onInternalError", "onBlockCap"]) {
129
+ if (floorPolicy[key] === "block") config.policy[key] = "block";
130
+ }
131
+
132
+ if (floorPolicy.reviewScope === "all-code") {
133
+ config.policy.reviewScope = "all-code";
134
+ }
135
+
136
+ if (floor.privacy?.externalReview === "deny") {
137
+ config.privacy.externalReview = "deny";
138
+ }
139
+ if (floor.privacy?.secretScan === "block-all") {
140
+ config.privacy.secretScan = "block-all";
141
+ }
142
+
143
+ return config;
144
+ }
145
+
146
+ /**
147
+ * Produce the final resolved config by merging project config on top of
148
+ * DEFAULT_CONFIG and then enforcing the user's policy floor.
149
+ *
150
+ * @param {object} [projectConfig={}] - raw config loaded from project
151
+ * @param {object} [userPolicyFloor={}] - user-level floor settings
152
+ * @returns {object} resolved config
153
+ */
154
+ export function mergeConfig(projectConfig = {}, userPolicyFloor = {}) {
155
+ const merged = structuredClone(DEFAULT_CONFIG);
156
+ deepAssign(merged, sanitizeProjectConfig(projectConfig));
157
+ return applyPolicyFloor(merged, userPolicyFloor);
158
+ }