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
package/src/core/git.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Hard cap on accumulated stdout. A pathological git diff (e.g. a huge generated
|
|
4
|
+
// file) could otherwise grow stdout without bound and OOM the process. When the
|
|
5
|
+
// cap is exceeded we kill the child and resolve with what we have plus a
|
|
6
|
+
// `truncated` flag so callers can flag a coverage limitation instead of silently
|
|
7
|
+
// dropping output.
|
|
8
|
+
const MAX_STDOUT_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
// Spawn a git subprocess and resolve with its exit code and captured output.
|
|
11
|
+
// Never rejects; an exec error (e.g. git missing) resolves with code 127 so
|
|
12
|
+
// callers can branch on `result.code` uniformly. The resolved object always
|
|
13
|
+
// carries a `truncated` field (falsy for normal-size output).
|
|
14
|
+
export async function git(args, cwd, options = {}) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn("git", args, { cwd, shell: false, windowsHide: true });
|
|
17
|
+
const stdoutChunks = [];
|
|
18
|
+
let stdoutBytes = 0;
|
|
19
|
+
let stderr = "";
|
|
20
|
+
let truncated = false;
|
|
21
|
+
let settled = false;
|
|
22
|
+
|
|
23
|
+
const finish = (result) => {
|
|
24
|
+
if (settled) return;
|
|
25
|
+
settled = true;
|
|
26
|
+
resolve(result);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
child.stdout.on("data", (chunk) => {
|
|
30
|
+
if (truncated) return;
|
|
31
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
32
|
+
if (stdoutBytes + buf.length > MAX_STDOUT_BYTES) {
|
|
33
|
+
// Keep only up to the cap, then stop the child to bound memory.
|
|
34
|
+
const remaining = MAX_STDOUT_BYTES - stdoutBytes;
|
|
35
|
+
if (remaining > 0) {
|
|
36
|
+
stdoutChunks.push(buf.subarray(0, remaining));
|
|
37
|
+
stdoutBytes += remaining;
|
|
38
|
+
}
|
|
39
|
+
truncated = true;
|
|
40
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
41
|
+
finish({
|
|
42
|
+
code: null,
|
|
43
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
44
|
+
stderr,
|
|
45
|
+
truncated: true,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
stdoutChunks.push(buf);
|
|
50
|
+
stdoutBytes += buf.length;
|
|
51
|
+
});
|
|
52
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
53
|
+
child.on("error", (error) =>
|
|
54
|
+
finish({ code: 127, stdout: Buffer.concat(stdoutChunks).toString("utf8"), stderr: String(error), truncated })
|
|
55
|
+
);
|
|
56
|
+
child.on("close", (code) =>
|
|
57
|
+
finish({ code, stdout: Buffer.concat(stdoutChunks).toString("utf8"), stderr, truncated })
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Return true if `cwd` is inside a git working tree.
|
|
63
|
+
export async function isGitRepo(cwd) {
|
|
64
|
+
const result = await git(["rev-parse", "--git-dir"], cwd);
|
|
65
|
+
return result.code === 0;
|
|
66
|
+
}
|
package/src/core/hash.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function sha256(text) {
|
|
4
|
+
return createHash("sha256").update(String(text), "utf8").digest("hex");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function stableJson(value) {
|
|
8
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
9
|
+
if (value && typeof value === "object") {
|
|
10
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
11
|
+
}
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function reviewCacheKey(parts) {
|
|
16
|
+
return sha256(stableJson({
|
|
17
|
+
diffHash: parts.diffHash,
|
|
18
|
+
configHash: parts.configHash,
|
|
19
|
+
promptHash: parts.promptHash,
|
|
20
|
+
reviewerId: parts.reviewerId,
|
|
21
|
+
reviewerVersion: parts.reviewerVersion,
|
|
22
|
+
model: parts.model || "",
|
|
23
|
+
level: parts.level,
|
|
24
|
+
toolVersion: parts.toolVersion,
|
|
25
|
+
privacyMode: parts.privacyMode,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Config + state-dir loading for the CLI entrypoints.
|
|
2
|
+
//
|
|
3
|
+
// This module centralizes how the `check`, `hook`, and `run` commands resolve
|
|
4
|
+
// their effective config and where they keep per-session state.
|
|
5
|
+
//
|
|
6
|
+
// HARDENING #1 (Task 8 review): the gate's review-pass cache lives in session
|
|
7
|
+
// state. A pre-seeded cache entry would yield an UNREVIEWED pass. Therefore the
|
|
8
|
+
// state directory MUST live at a USER-LEVEL path (under the user's home dir),
|
|
9
|
+
// never a repo-relative path that an untrusted project could pre-write. The
|
|
10
|
+
// default is `~/.adversarial-review/state`. A test-only override is available
|
|
11
|
+
// via the ADVERSARIAL_REVIEW_STATE_DIR env var, but the DEFAULT is always
|
|
12
|
+
// user-level and never under `cwd`.
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { DEFAULT_CONFIG, sanitizeProjectConfig, deepAssign, applyPolicyFloor } from "./config.js";
|
|
18
|
+
|
|
19
|
+
const PROJECT_CONFIG_REL = path.join(".adversarial-review", "config.json");
|
|
20
|
+
const USER_CONFIG_REL = path.join(".adversarial-review", "config.json");
|
|
21
|
+
const USER_POLICY_REL = path.join(".adversarial-review", "policy.json");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tolerantly read+parse a JSON file. Missing file -> `{}` (no warning). Corrupt
|
|
25
|
+
* JSON -> `{}` plus a warning written to `stderr` (when provided). Never throws.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} file
|
|
28
|
+
* @param {object} [io] - { stderr }
|
|
29
|
+
* @param {string} [label]
|
|
30
|
+
* @returns {Promise<object>}
|
|
31
|
+
*/
|
|
32
|
+
async function readJsonTolerant(file, io, label) {
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = await readFile(file, "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
return {}; // Missing/unreadable: treat as empty config.
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
42
|
+
return {};
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (io?.stderr) {
|
|
45
|
+
io.stderr.write(
|
|
46
|
+
`adversarial-review: ignoring corrupt ${label || "config"} at ${file}: ${err.message}\n`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load the effective config for a workspace.
|
|
55
|
+
*
|
|
56
|
+
* Merge precedence (lowest to highest):
|
|
57
|
+
* DEFAULT_CONFIG < userConfig < projectConfig
|
|
58
|
+
* where:
|
|
59
|
+
* - userConfig comes from `<home>/.adversarial-review/config.json` and acts
|
|
60
|
+
* as machine-wide host/reviewer DEFAULTS (e.g. so "claude-code+codex use
|
|
61
|
+
* opencode" applies machine-wide without a per-project config);
|
|
62
|
+
* - projectConfig comes from `<cwd>/.adversarial-review/config.json` and
|
|
63
|
+
* overrides the user defaults for any key it sets.
|
|
64
|
+
* The user policy floor (`<home>/.adversarial-review/policy.json`) is then applied
|
|
65
|
+
* ON TOP via applyPolicyFloor, so it can only RATCHET STRICTER — neither the user
|
|
66
|
+
* config nor the project config can loosen it.
|
|
67
|
+
*
|
|
68
|
+
* All three files are tolerant (missing -> {}, corrupt -> {} + warning).
|
|
69
|
+
*
|
|
70
|
+
* @param {string} cwd
|
|
71
|
+
* @param {object} [io] - { stderr, env }
|
|
72
|
+
* @returns {Promise<object>} resolved config
|
|
73
|
+
*/
|
|
74
|
+
export async function loadEffectiveConfig(cwd, io = {}) {
|
|
75
|
+
const home = homeDir(io.env);
|
|
76
|
+
const userConfig = await readJsonTolerant(
|
|
77
|
+
path.join(home, USER_CONFIG_REL),
|
|
78
|
+
io,
|
|
79
|
+
"user config"
|
|
80
|
+
);
|
|
81
|
+
const projectConfig = await readJsonTolerant(
|
|
82
|
+
path.join(cwd, PROJECT_CONFIG_REL),
|
|
83
|
+
io,
|
|
84
|
+
"project config"
|
|
85
|
+
);
|
|
86
|
+
const userPolicyFloor = await readJsonTolerant(
|
|
87
|
+
path.join(home, USER_POLICY_REL),
|
|
88
|
+
io,
|
|
89
|
+
"user policy"
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Layer lowest-to-highest: DEFAULT_CONFIG < userConfig < projectConfig.
|
|
93
|
+
// Both raw layers are sanitized (unknown top-level keys stripped) and merged
|
|
94
|
+
// via deepAssign, which also blocks prototype-pollution keys.
|
|
95
|
+
const merged = structuredClone(DEFAULT_CONFIG);
|
|
96
|
+
deepAssign(merged, sanitizeProjectConfig(userConfig));
|
|
97
|
+
deepAssign(merged, sanitizeProjectConfig(projectConfig));
|
|
98
|
+
|
|
99
|
+
// Apply the user policy floor LAST so it can only tighten, never loosen.
|
|
100
|
+
return applyPolicyFloor(merged, userPolicyFloor);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the user-level state directory.
|
|
105
|
+
*
|
|
106
|
+
* DEFAULT: `<home>/.adversarial-review/state` — always OUTSIDE any `cwd`, where
|
|
107
|
+
* `<home>` is the resolved user home (see homeDir, honoring ADVERSARIAL_REVIEW_HOME).
|
|
108
|
+
* Override: the ADVERSARIAL_REVIEW_STATE_DIR env var (tests only) takes priority
|
|
109
|
+
* over the home-based default. The default path is never repo-relative, so a
|
|
110
|
+
* project can never pre-seed the pass cache.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} [env=process.env]
|
|
113
|
+
* @returns {string} absolute state dir path
|
|
114
|
+
*/
|
|
115
|
+
export function resolveStateDir(env = process.env) {
|
|
116
|
+
const override = env && env.ADVERSARIAL_REVIEW_STATE_DIR;
|
|
117
|
+
if (override) return path.resolve(override);
|
|
118
|
+
return path.join(homeDir(env), ".adversarial-review", "state");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Resolve the user's home directory, honoring an injected env so tests can
|
|
122
|
+
// redirect the user-level base (config.json, policy.json, state dir) without
|
|
123
|
+
// touching the real home dir. Priority:
|
|
124
|
+
// 1. ADVERSARIAL_REVIEW_HOME — dedicated override for the user-level base;
|
|
125
|
+
// 2. HOME / USERPROFILE — standard OS home env vars;
|
|
126
|
+
// 3. os.homedir() — the real home dir.
|
|
127
|
+
function homeDir(env) {
|
|
128
|
+
if (env) {
|
|
129
|
+
const fromEnv = env.ADVERSARIAL_REVIEW_HOME || env.HOME || env.USERPROFILE;
|
|
130
|
+
if (fromEnv) return fromEnv;
|
|
131
|
+
}
|
|
132
|
+
return os.homedir();
|
|
133
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonicalize a candidate path relative to workspaceRoot and determine
|
|
6
|
+
* whether it escapes the workspace (path traversal / symlink escape guard).
|
|
7
|
+
*
|
|
8
|
+
* Algorithm:
|
|
9
|
+
* 1. Resolve the real path of workspaceRoot itself.
|
|
10
|
+
* 2. Build the absolute form of candidate.
|
|
11
|
+
* 3. Resolve the real path of its *parent* directory (catches symlinks that
|
|
12
|
+
* would redirect the directory outside the workspace). If the parent does
|
|
13
|
+
* not exist yet, fall back to the un-resolved parent (creation paths).
|
|
14
|
+
* 4. Reconstruct the full path by joining real-parent + basename.
|
|
15
|
+
* 5. Compute path.relative(rootReal, resolved). An empty relative means
|
|
16
|
+
* the candidate IS the root; a relative that starts with ".." or is
|
|
17
|
+
* absolute means it escaped.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} workspaceRoot - absolute path to the workspace root
|
|
20
|
+
* @param {string} candidate - path to check (may be relative or absolute)
|
|
21
|
+
* @returns {Promise<{rootReal: string, absolute: string, relative: string, outside: boolean}>}
|
|
22
|
+
*/
|
|
23
|
+
export async function canonicalWorkspacePath(workspaceRoot, candidate) {
|
|
24
|
+
const rootReal = await realpath(workspaceRoot);
|
|
25
|
+
const absolute = path.resolve(workspaceRoot, candidate);
|
|
26
|
+
const parentReal = await realpath(path.dirname(absolute)).catch(
|
|
27
|
+
() => path.dirname(absolute)
|
|
28
|
+
);
|
|
29
|
+
const resolved = path.join(parentReal, path.basename(absolute));
|
|
30
|
+
const rel = path.relative(rootReal, resolved);
|
|
31
|
+
const outside = rel === "" ? false : rel.startsWith("..") || path.isAbsolute(rel);
|
|
32
|
+
return { rootReal, absolute: resolved, relative: rel, outside };
|
|
33
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Policy helper functions — derive boolean/string decisions from a resolved config.
|
|
2
|
+
// All functions are pure (no side effects, no state).
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns true when the effective policy mode is "strict-ci".
|
|
6
|
+
*
|
|
7
|
+
* @param {object} config - resolved config (from mergeConfig)
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function isStrict(config) {
|
|
11
|
+
return config.policy.mode === "strict-ci";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns true when every code change must be reviewed regardless of size.
|
|
16
|
+
* True for "all-code" reviewScope or when mode is "strict-ci".
|
|
17
|
+
*
|
|
18
|
+
* @param {object} config
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function requiresReviewForCode(config) {
|
|
22
|
+
return config.policy.reviewScope === "all-code" || isStrict(config);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the action to take when a reviewer call fails.
|
|
27
|
+
* In "soft" mode the configured value (or "self-review") is returned.
|
|
28
|
+
* In all other modes the configured value (or "block") is returned.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} config
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function reviewerErrorAction(config) {
|
|
34
|
+
if (config.policy.mode === "soft") return config.policy.onReviewerError || "self-review";
|
|
35
|
+
return config.policy.onReviewerError || "block";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the action to take when an internal (tool-level) error occurs.
|
|
40
|
+
* When there is no evidence of a significant change the gate should never
|
|
41
|
+
* block — the change either hasn't happened or is trivial.
|
|
42
|
+
* In "soft" mode the configured value (or "allow") is returned.
|
|
43
|
+
* In all other modes the configured value (or "block") is returned.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} config
|
|
46
|
+
* @param {boolean} evidenceOfSignificantChange
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function internalErrorAction(config, evidenceOfSignificantChange) {
|
|
50
|
+
if (!evidenceOfSignificantChange) return "allow";
|
|
51
|
+
if (config.policy.mode === "soft") return config.policy.onInternalError || "allow";
|
|
52
|
+
return config.policy.onInternalError || "block";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns the action to take when consecutive block count reaches the cap.
|
|
57
|
+
* In "soft" mode the configured value (or "allow") is returned.
|
|
58
|
+
* In all other modes the configured value (or "block") is returned.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} config
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function blockCapAction(config) {
|
|
64
|
+
if (config.policy.mode === "soft") return config.policy.onBlockCap || "allow";
|
|
65
|
+
return config.policy.onBlockCap || "block";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns true when the current config permits a review to be skipped.
|
|
70
|
+
* Skip is never allowed in "strict-ci" mode, regardless of allowSkip setting.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} config
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function skipAllowed(config) {
|
|
76
|
+
return config.policy.mode !== "strict-ci" && config.policy.allowSkip === true;
|
|
77
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a command name or path to an absolute executable path.
|
|
8
|
+
* On Windows, walks PATHEXT extensions (e.g. .COM .EXE .BAT .CMD).
|
|
9
|
+
* Returns null if nothing is found.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} command - bare name ("claude") or explicit path
|
|
12
|
+
* @param {object} env - environment variables (defaults to process.env)
|
|
13
|
+
* @returns {Promise<string|null>}
|
|
14
|
+
*/
|
|
15
|
+
export async function resolveExecutable(command, env = process.env) {
|
|
16
|
+
// Explicit path: check existence and return resolved form, or null if missing.
|
|
17
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
18
|
+
try {
|
|
19
|
+
await access(command, constants.X_OK);
|
|
20
|
+
} catch {
|
|
21
|
+
try {
|
|
22
|
+
await access(command, constants.F_OK);
|
|
23
|
+
} catch {
|
|
24
|
+
// File does not exist or is inaccessible — return null instead of throwing.
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return path.resolve(command);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pathEntries = String(env.PATH || "").split(path.delimiter).filter(Boolean);
|
|
32
|
+
const extensions =
|
|
33
|
+
process.platform === "win32"
|
|
34
|
+
? String(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
|
|
35
|
+
: [""];
|
|
36
|
+
|
|
37
|
+
for (const dir of pathEntries) {
|
|
38
|
+
for (const ext of extensions) {
|
|
39
|
+
const candidate = path.join(
|
|
40
|
+
dir,
|
|
41
|
+
process.platform === "win32" ? `${command}${ext}` : command
|
|
42
|
+
);
|
|
43
|
+
try {
|
|
44
|
+
await access(candidate, constants.F_OK);
|
|
45
|
+
return candidate;
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Spawn a child process with shell:false to prevent shell-injection.
|
|
56
|
+
* stdio defaults to ["ignore", "pipe", "pipe"].
|
|
57
|
+
*
|
|
58
|
+
* @param {string} command
|
|
59
|
+
* @param {string[]} args
|
|
60
|
+
* @param {object} options - { cwd, env, stdio }
|
|
61
|
+
* @returns {import("node:child_process").ChildProcess}
|
|
62
|
+
*/
|
|
63
|
+
export function spawnSafe(command, args, options = {}) {
|
|
64
|
+
return spawn(command, args, {
|
|
65
|
+
cwd: options.cwd,
|
|
66
|
+
env: options.env,
|
|
67
|
+
shell: false,
|
|
68
|
+
stdio: options.stdio || ["ignore", "pipe", "pipe"],
|
|
69
|
+
windowsHide: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Characters that cmd.exe treats as metacharacters when it re-parses the
|
|
74
|
+
// trailing arguments of `cmd.exe /c <batch> <args...>`. An argument containing
|
|
75
|
+
// any of these can break out of the intended command and execute attacker code,
|
|
76
|
+
// so batch-wrapped invocations MUST reject args matching this pattern.
|
|
77
|
+
const CMD_METACHAR_RE = /[&|<>^"%()\r\n]/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Spawn a RESOLVED executable path with shell:false.
|
|
81
|
+
*
|
|
82
|
+
* On Windows, `.cmd` and `.bat` files cannot be spawned directly with
|
|
83
|
+
* shell:false — they must be invoked via `cmd.exe /c <path> [args...]`.
|
|
84
|
+
* This function handles that transparently so callers never need to
|
|
85
|
+
* special-case Windows batch wrappers.
|
|
86
|
+
*
|
|
87
|
+
* SECURITY: when wrapping a `.cmd`/`.bat` target, cmd.exe re-parses the trailing
|
|
88
|
+
* arguments, so an argument containing cmd metacharacters
|
|
89
|
+
* (`& | < > ^ " % ( )`, CR/LF) would execute attacker-controlled commands. This
|
|
90
|
+
* function FAILS CLOSED: if any arg passed to a batch wrapper matches a cmd
|
|
91
|
+
* metacharacter it THROWS `unsafe_batch_argument` BEFORE spawning. Callers must
|
|
92
|
+
* therefore never hand free-text (prompts, briefs, repo content) directly as a
|
|
93
|
+
* batch argument — pass such data via a temp file path or the child's stdin.
|
|
94
|
+
* Non-batch (`.exe`/direct) targets are spawned via CreateProcess with no shell
|
|
95
|
+
* and are unaffected by this check.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} resolvedPath - absolute path returned by resolveExecutable
|
|
98
|
+
* @param {string[]} args
|
|
99
|
+
* @param {object} options - { cwd, env, stdio }
|
|
100
|
+
* @returns {import("node:child_process").ChildProcess}
|
|
101
|
+
* @throws {Error} "unsafe_batch_argument" when a batch wrapper would receive a
|
|
102
|
+
* cmd-metacharacter argument.
|
|
103
|
+
*/
|
|
104
|
+
export function spawnResolved(resolvedPath, args, options = {}) {
|
|
105
|
+
let command = resolvedPath;
|
|
106
|
+
let finalArgs = args;
|
|
107
|
+
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
const lower = resolvedPath.toLowerCase();
|
|
110
|
+
if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
|
|
111
|
+
// Defense-in-depth (Layer B): reject any cmd-metacharacter argument BEFORE
|
|
112
|
+
// spawning. cmd.exe /c re-parses these args, so this prevents the trailing
|
|
113
|
+
// arguments from breaking out into attacker-controlled commands.
|
|
114
|
+
for (const arg of args) {
|
|
115
|
+
if (CMD_METACHAR_RE.test(String(arg))) {
|
|
116
|
+
throw new Error("unsafe_batch_argument");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Wrap batch files with cmd.exe /c to avoid EINVAL with shell:false.
|
|
120
|
+
command = "cmd.exe";
|
|
121
|
+
finalArgs = ["/c", resolvedPath, ...args];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return spawn(command, finalArgs, {
|
|
126
|
+
cwd: options.cwd,
|
|
127
|
+
env: options.env,
|
|
128
|
+
shell: false,
|
|
129
|
+
stdio: options.stdio || ["ignore", "pipe", "pipe"],
|
|
130
|
+
windowsHide: true,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Custom-reviewer argument template validation
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/** Placeholders that a custom reviewer's args array may use. */
|
|
139
|
+
export const ALLOWED_PLACEHOLDERS = new Set(["cwd", "diffPath", "briefPath", "jobPath"]);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Expand `{placeholder}` tokens in a reviewer args array.
|
|
143
|
+
* Throws if an unknown placeholder is encountered (injection guard).
|
|
144
|
+
*
|
|
145
|
+
* @param {string[]} args - template args from reviewer config
|
|
146
|
+
* @param {object} values - map of placeholder → value
|
|
147
|
+
* @returns {string[]}
|
|
148
|
+
*/
|
|
149
|
+
export function expandArgs(args, values) {
|
|
150
|
+
return args.map((arg) =>
|
|
151
|
+
String(arg).replace(/\{([^}]+)\}/g, (_m, name) => {
|
|
152
|
+
if (!ALLOWED_PLACEHOLDERS.has(name)) {
|
|
153
|
+
throw new Error(`Unknown custom reviewer placeholder: ${name}`);
|
|
154
|
+
}
|
|
155
|
+
return values[name] || "";
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Secret scanner: detects likely credentials in diff text and flags sensitive
|
|
2
|
+
// file paths. Used by the gate to block sending secrets to external reviewers.
|
|
3
|
+
|
|
4
|
+
const SECRET_PATTERNS = [
|
|
5
|
+
// Matches PEM-format private key headers, including ECDSA and DSA variants.
|
|
6
|
+
/-----BEGIN (?:RSA |EC |ECDSA |DSA |OPENSSH |PGP )?PRIVATE KEY-----/,
|
|
7
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
8
|
+
/\bghp_[A-Za-z0-9_]{30,}\b/,
|
|
9
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
10
|
+
/\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{12,}/i,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// Matches file paths that are inherently sensitive: .env files, credential/
|
|
14
|
+
// secret/private-key names, SSH key file names, and common key/cert extensions.
|
|
15
|
+
const SENSITIVE_PATH_RE =
|
|
16
|
+
/(^|[\\/])\.env(\.|$)|credential|secret|private[-_]?key|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.pem|\.pfx|\.p12|\.key|\.keystore|\.jks/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scan diff text and file paths for potential secrets or sensitive material.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|*} text - Raw diff or file content to scan. Non-string values
|
|
22
|
+
* are coerced to string; null/undefined become an empty string.
|
|
23
|
+
* @param {string[]|null|undefined} [paths=[]] - File paths included in the
|
|
24
|
+
* change set. null or undefined are treated as an empty array.
|
|
25
|
+
* @returns {Array<{ type: "sensitive_path", path: string } | { type: "secret_pattern", sample: string }>}
|
|
26
|
+
*/
|
|
27
|
+
export function scanSecrets(text, paths = []) {
|
|
28
|
+
// Guard against non-string text (null, undefined, numbers, etc.).
|
|
29
|
+
const body = typeof text === "string" ? text : String(text ?? "");
|
|
30
|
+
// Guard against null/undefined paths — only iterate an actual array.
|
|
31
|
+
const list = Array.isArray(paths) ? paths : [];
|
|
32
|
+
|
|
33
|
+
const findings = [];
|
|
34
|
+
// Flag paths that are inherently sensitive regardless of content.
|
|
35
|
+
for (const filePath of list) {
|
|
36
|
+
if (SENSITIVE_PATH_RE.test(filePath)) {
|
|
37
|
+
findings.push({ type: "sensitive_path", path: filePath });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Scan text for known secret shapes.
|
|
41
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
42
|
+
const match = pattern.exec(body);
|
|
43
|
+
if (match) findings.push({ type: "secret_pattern", sample: match[0].slice(0, 12) });
|
|
44
|
+
}
|
|
45
|
+
return findings;
|
|
46
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Per-session gate state persistence.
|
|
2
|
+
//
|
|
3
|
+
// State is a small JSON file per session under `stateDir`. It holds the
|
|
4
|
+
// session baseline (recorded by the SessionStart hook in a later task), a
|
|
5
|
+
// consecutive-block counter, and a review-pass cache keyed by reviewCacheKey.
|
|
6
|
+
//
|
|
7
|
+
// All reads are tolerant: a missing or corrupt file yields a default `{}` so
|
|
8
|
+
// the gate never crashes on a fresh or damaged state directory. Writes are
|
|
9
|
+
// atomic (temp file + rename) and use owner-only permissions where supported.
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile, rename, mkdir, readdir, stat, unlink } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { sha256 } from "./hash.js";
|
|
14
|
+
|
|
15
|
+
// Owner read/write only. Honored on POSIX; a no-op effect on Windows but safe.
|
|
16
|
+
const FILE_MODE = 0o600;
|
|
17
|
+
const DIR_MODE = 0o700;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Derive a safe on-disk file name for a session id. The id is hashed so that
|
|
21
|
+
* arbitrary characters (path separators, etc.) in a host-provided session id
|
|
22
|
+
* cannot escape the state directory.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} sessionId
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function sessionFileName(sessionId) {
|
|
28
|
+
return `session-${sha256(String(sessionId || "default"))}.json`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read the persisted state for a session.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} stateDir
|
|
35
|
+
* @param {string} sessionId
|
|
36
|
+
* @returns {Promise<object>} the stored state, or `{}` if missing/corrupt.
|
|
37
|
+
*/
|
|
38
|
+
export async function readSessionState(stateDir, sessionId) {
|
|
39
|
+
const file = join(stateDir, sessionFileName(sessionId));
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = await readFile(file, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
49
|
+
return {};
|
|
50
|
+
} catch {
|
|
51
|
+
// Corrupt JSON: treat as empty rather than crashing the gate.
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Atomically persist a session's state. Writes a temp file then renames it into
|
|
58
|
+
* place so a concurrent reader never observes a half-written file.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} stateDir
|
|
61
|
+
* @param {string} sessionId
|
|
62
|
+
* @param {object} state
|
|
63
|
+
* @returns {Promise<void>}
|
|
64
|
+
*/
|
|
65
|
+
export async function writeSessionState(stateDir, sessionId, state) {
|
|
66
|
+
await mkdir(stateDir, { recursive: true, mode: DIR_MODE });
|
|
67
|
+
const file = join(stateDir, sessionFileName(sessionId));
|
|
68
|
+
// Unique temp name so concurrent writers do not clobber each other's temp.
|
|
69
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
70
|
+
const payload = { ...state, updatedAt: Date.now() };
|
|
71
|
+
await writeFile(tmp, JSON.stringify(payload), { mode: FILE_MODE });
|
|
72
|
+
await rename(tmp, file);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Delete session state files whose last update is older than `ttlDays`.
|
|
77
|
+
* Tolerant of unreadable entries and a missing state directory.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} stateDir
|
|
80
|
+
* @param {number} ttlDays
|
|
81
|
+
* @param {number} [now=Date.now()]
|
|
82
|
+
* @returns {Promise<number>} count of files removed.
|
|
83
|
+
*/
|
|
84
|
+
export async function pruneState(stateDir, ttlDays, now = Date.now()) {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = await readdir(stateDir);
|
|
88
|
+
} catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const ttlMs = Math.max(0, Number(ttlDays) || 0) * 24 * 60 * 60 * 1000;
|
|
92
|
+
let removed = 0;
|
|
93
|
+
for (const name of entries) {
|
|
94
|
+
if (!name.startsWith("session-") || !name.endsWith(".json")) continue;
|
|
95
|
+
const file = join(stateDir, name);
|
|
96
|
+
try {
|
|
97
|
+
const info = await stat(file);
|
|
98
|
+
if (now - info.mtimeMs > ttlMs) {
|
|
99
|
+
await unlink(file);
|
|
100
|
+
removed += 1;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Ignore races / unreadable entries.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return removed;
|
|
107
|
+
}
|