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,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
+ }
@@ -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
+ }