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/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
|
+
}
|