feat(policy-loader): 8.7 — env-var deprecation warnings (v8.0.0 removal)

This commit is contained in:
Kjell Tore Guttormsen 2026-04-30 17:11:07 +02:00
commit ba5f2b64ad
8 changed files with 252 additions and 24 deletions

View file

@ -36,6 +36,11 @@ Built on OWASP LLM Top 10 (2025), OWASP Agentic AI Top 10, and the AI Agent Trap
- **Deterministic scanning** — 23 Node.js scanners (10 orchestrated + 13 standalone) for byte-level analysis: Shannon entropy, Unicode codepoints, typosquatting detection, taint flow, DNS resolution, git forensics, AI-BOM, attack simulation, IDE extension prescan (VS Code + JetBrains — URL fetch from Marketplace / OpenVSX / direct VSIX / JetBrains Marketplace, hardened ZIP extractor for zip-slip / symlinks / bombs, plus OS sandbox via `sandbox-exec` / `bwrap` so the kernel enforces FS confinement), MCP cumulative-drift baseline reset (E14 — sticky baseline catches slow-burn rug-pulls). Bash-normalize T1-T6 for obfuscation-resistant denylists
- **Advisory analysis** — 20 commands that scan, audit, and model threats with structured reports, letter grades, and actionable remediation
- **Enterprise governance** — Compliance mapping (EU AI Act, NIST AI RMF, ISO 42001), SARIF 2.1.0 output, structured audit trail, policy-as-code, standalone CLI
- **v8.0.0 env-var deprecation runway (D3, v7.3.0)** — Hook configuration has historically been split between process env-vars and the team-distributable `.llm-security/policy.json` file. Until v7.3.0 the two surfaces could disagree silently. The new `getPolicyValueWithEnvWarn()` helper in `scanners/lib/policy-loader.mjs` now emits a one-time-per-process stderr line whenever both surfaces are explicitly set:
- Affected pairs: `LLM_SECURITY_INJECTION_MODE``injection.mode`, `LLM_SECURITY_TRIFECTA_MODE``trifecta.mode`, `LLM_SECURITY_ESCALATION_WINDOW``trifecta.escalation_window` (new key in `DEFAULT_POLICY`), `LLM_SECURITY_AUDIT_LOG``audit.log_path`
- Env still wins through the v7.x window — no behaviour change today, only a runway signal
- Suppress headless-log noise with `LLM_SECURITY_DEPRECATION_QUIET=1`
- Teams should converge on `policy.json` for distributable configuration before v8.0.0 removes the env-var path
- **Opus 4.7 aligned** — Agent instructions rewritten for literal instruction-following (system card §6.3.1.1), defense-in-depth posture per §5.2.1, production hardening guide
Key commands: `/security posture`, `/security audit`, `/security scan`, `/security ide-scan`, `/security threat-model`, `/security plugin-audit`

View file

@ -45,6 +45,30 @@ history are preserved for audit. `LLM_SECURITY_MCP_CACHE_FILE` env var
overrides the cache path for end-to-end testing without polluting the
user's real `~/.cache/llm-security/mcp-descriptions.json`.
**v7.3.0 — Env-var deprecation warnings (D3 of Batch C, Wave D).**
Closes 8.7 from `.claude/projects/2026-04-29-batch-c-scope-finalize/plan.md`.
`scanners/lib/policy-loader.mjs` exports a new helper
`getPolicyValueWithEnvWarn(section, key, envVarName, defaultValue)`
env still wins per Preferences (existing behaviour), but when both the
env-var AND the `policy.json` key are explicitly set, the helper emits a
single per-process stderr line: `[llm-security] Deprecation: env-var
${ENVVAR} will be removed in v8.0.0; policy.json key ${section}.${key}
also set — env wins for now. Suppress with LLM_SECURITY_DEPRECATION_QUIET=1.`
Module-scoped `Set` dedupes per env-var name across call-sites. Four
overlapping vars are wired through the helper:
`LLM_SECURITY_INJECTION_MODE``injection.mode` (in
`pre-prompt-inject-scan.mjs`), `LLM_SECURITY_TRIFECTA_MODE`
`trifecta.mode` and `LLM_SECURITY_ESCALATION_WINDOW`
`trifecta.escalation_window` (in `post-session-guard.mjs`),
`LLM_SECURITY_AUDIT_LOG``audit.log_path` (in
`scanners/lib/audit-trail.mjs`). `DEFAULT_POLICY` gains
`trifecta.escalation_window: 5` to close the gap noted in the plan
revisions table (M10). Env-only vars without policy.json equivalents
(`LLM_SECURITY_UPDATE_CHECK`, `LLM_SECURITY_PRECOMPACT_MODE`,
`LLM_SECURITY_PRECOMPACT_MAX_BYTES`, `LLM_SECURITY_IDE_ROOTS`,
`LLM_SECURITY_MCP_CACHE_FILE`) are unchanged — they emit no
deprecation signal because there is nothing to deprecate yet.
## Commands
| Command | Description |

View file

@ -410,6 +410,22 @@ For deep scans (`/security scan --deep` or `/security deep-scan`), deterministic
The baseline survives the 7-day TTL purge so detection persists across the full window. After a legitimate MCP server upgrade, run `/security mcp-baseline-reset` (or `node scanners/mcp-baseline-reset.mjs --target <tool>`) to clear the stale baseline. The next call seeds a fresh baseline from the incoming description; description, firstSeen, lastSeen, and history are preserved across reset for audit. `LLM_SECURITY_MCP_CACHE_FILE` env var overrides the cache path for testing without polluting `~/.cache/llm-security/mcp-descriptions.json`.
### Env-var Deprecation Runway to v8.0.0 (D3, v7.3.0)
Hook configuration has historically been split between two equally valid surfaces: process env-vars (e.g. `LLM_SECURITY_TRIFECTA_MODE=warn`) and the team-distributable `.llm-security/policy.json` file. Env still wins per the original Preferences contract, but until v7.3.0 there was no signal when the two surfaces disagreed — a developer could set `policy.json` to `block` while their shell exported `warn`, and the warn-value would silently override.
`scanners/lib/policy-loader.mjs` now exports `getPolicyValueWithEnvWarn(section, key, envVarName, defaultValue)`. The helper resolves the value with the existing env-wins contract, but when both the env-var AND the `policy.json` key are set explicitly (heuristic: resolved policy value differs from `defaultValue`), it emits a single per-process stderr line:
```
[llm-security] Deprecation: env-var ${ENVVAR} will be removed in v8.0.0;
policy.json key ${section}.${key} also set — env wins for now.
Suppress with LLM_SECURITY_DEPRECATION_QUIET=1.
```
A module-scoped `Set` dedupes per env-var name across call-sites within the same hook process. Four overlapping pairs are wired through the helper today: `LLM_SECURITY_INJECTION_MODE``injection.mode` (in `pre-prompt-inject-scan.mjs`), `LLM_SECURITY_TRIFECTA_MODE``trifecta.mode` and `LLM_SECURITY_ESCALATION_WINDOW``trifecta.escalation_window` (in `post-session-guard.mjs`), and `LLM_SECURITY_AUDIT_LOG``audit.log_path` (in `scanners/lib/audit-trail.mjs`). `DEFAULT_POLICY` gains `trifecta.escalation_window: 5` to close the previously-unmappable gap. Env-only vars without policy equivalents (`LLM_SECURITY_UPDATE_CHECK`, `LLM_SECURITY_PRECOMPACT_*`, `LLM_SECURITY_IDE_ROOTS`, `LLM_SECURITY_MCP_CACHE_FILE`) are unchanged — there is nothing to deprecate yet.
The runway is intentionally long: v7.3.0 ships the warning, v8.0.0 removes the env-var read entirely. Teams running CI with both surfaces set should converge on `policy.json` over the v7.x window. Set `LLM_SECURITY_DEPRECATION_QUIET=1` for the transition period if the warnings are noisy in headless logs.
**Why deterministic?** LLMs are powerful at semantic analysis — understanding intent, detecting social engineering, assessing context. But they cannot reliably calculate Shannon entropy, measure Levenshtein distance between package names, trace taint flow across function boundaries, or detect individual Unicode codepoints. These scanners fill that gap.
**Shared library** (`scanners/lib/`): severity classification, string utilities (entropy, Levenshtein, base64 detection), output formatting, file discovery, and YAML frontmatter parsing.
@ -479,7 +495,7 @@ v6.0.0 adds an enterprise governance layer for standards-aware security operatio
| **SARIF 2.1.0 Output** | `--format sarif` flag on scan/deep-scan produces OASIS SARIF standard output for CI/CD integration (GitHub Advanced Security, Azure DevOps, SonarQube). |
| **Structured Audit Trail** | JSONL audit events (`audit-trail.mjs`) with ISO 8601 timestamps, OWASP category tags, and SIEM-ready schema. Configurable via `LLM_SECURITY_AUDIT_*` env vars. |
| **AI-BOM** | CycloneDX 1.6 Bill of Materials for AI components — models, MCP servers, plugins, knowledge files, hooks. `llm-security audit-bom <target>`. |
| **Policy-as-Code** | `.llm-security/policy.json` for distributable hook configuration. Teams can enforce consistent security thresholds without per-developer env var setup. |
| **Policy-as-Code** | `.llm-security/policy.json` for distributable hook configuration. Teams can enforce consistent security thresholds without per-developer env var setup. v7.3.0 (D3): when both an env-var and its overlapping `policy.json` key are set explicitly, hooks emit a one-time-per-process stderr line `[llm-security] Deprecation: env-var ${NAME} will be removed in v8.0.0; policy.json key ${section}.${key} also set — env wins for now. Suppress with LLM_SECURITY_DEPRECATION_QUIET=1.`. Affected pairs: `LLM_SECURITY_INJECTION_MODE``injection.mode`, `LLM_SECURITY_TRIFECTA_MODE``trifecta.mode`, `LLM_SECURITY_ESCALATION_WINDOW``trifecta.escalation_window` (new key in `DEFAULT_POLICY`), `LLM_SECURITY_AUDIT_LOG``audit.log_path`. Env still wins per existing contract; no behaviour change in v7.3.0 — the warning is the deprecation runway to v8.0.0. |
| **Standalone CLI** | `node bin/llm-security.mjs scan <target>` — runs scanners without Claude Code. Subcommands: `scan`, `posture`, `audit-bom`, `benchmark`. |
| **CI/CD Integration** | `--fail-on <severity>` for threshold-based exit codes, `--compact` for one-liner output. Pipeline templates for GitHub Actions, Azure DevOps, GitLab CI in `ci/`. Guide: `docs/ci-cd-guide.md`. |

View file

@ -43,7 +43,7 @@ import { createHash } from 'node:crypto';
import { extractMcpServer } from '../../scanners/lib/mcp-description-cache.mjs';
import { jensenShannonDivergence, buildDistribution } from '../../scanners/lib/distribution-stats.mjs';
import { writeAuditEvent } from '../../scanners/lib/audit-trail.mjs';
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
import { getPolicyValue, getPolicyValueWithEnvWarn } from '../../scanners/lib/policy-loader.mjs';
// ---------------------------------------------------------------------------
// Constants
@ -66,16 +66,24 @@ const DRIFT_SAMPLE_SIZE = 20;
// in the [primary, 20]-call range. Both reference an input_source; the
// secondary catches slow-burn variants where the attacker waits past the
// primary window before delegating.
// D3 (v7.3.0): env-var path emits a v8.0.0 deprecation warning when
// trifecta.escalation_window is also set in policy.json.
const DELEGATION_ESCALATION_WINDOW = (() => {
const envVal = parseInt(process.env.LLM_SECURITY_ESCALATION_WINDOW, 10);
if (Number.isFinite(envVal) && envVal > 0) return envVal;
return getPolicyValue('trifecta', 'escalation_window', 5);
const resolved = getPolicyValueWithEnvWarn(
'trifecta', 'escalation_window', 'LLM_SECURITY_ESCALATION_WINDOW', 5
);
const parsed = typeof resolved === 'string' ? parseInt(resolved, 10) : resolved;
if (Number.isFinite(parsed) && parsed > 0) return parsed;
return 5;
})();
const DELEGATION_ESCALATION_WINDOW_MEDIUM = 20; // secondary longer-window advisory
// Rule of Two enforcement mode: block | warn | off (env var takes precedence over policy)
const policyTrifectaMode = getPolicyValue('trifecta', 'mode', 'warn');
const TRIFECTA_MODE = (process.env.LLM_SECURITY_TRIFECTA_MODE || policyTrifectaMode).toLowerCase();
// Rule of Two enforcement mode: block | warn | off (env var takes precedence over policy).
// D3 (v7.3.0): env-var path emits a v8.0.0 deprecation warning when
// trifecta.mode is also set in policy.json.
const TRIFECTA_MODE = String(
getPolicyValueWithEnvWarn('trifecta', 'mode', 'LLM_SECURITY_TRIFECTA_MODE', 'warn')
).toLowerCase();
// Volume tracking thresholds (cumulative bytes per session)
const VOLUME_THRESHOLDS = [

View file

@ -21,16 +21,17 @@
import { readFileSync } from 'node:fs';
import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs';
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
import { getPolicyValueWithEnvWarn } from '../../scanners/lib/policy-loader.mjs';
// ---------------------------------------------------------------------------
// Mode configuration (env var takes precedence over policy file)
// Mode configuration (env var takes precedence over policy file; env-var path
// emits a v8.0.0 deprecation warning when policy.json also sets the key).
// ---------------------------------------------------------------------------
const VALID_MODES = new Set(['block', 'warn', 'off']);
const policyMode = getPolicyValue('injection', 'mode', 'block');
const mode = VALID_MODES.has(process.env.LLM_SECURITY_INJECTION_MODE)
? process.env.LLM_SECURITY_INJECTION_MODE
: VALID_MODES.has(policyMode) ? policyMode : 'block';
const resolved = getPolicyValueWithEnvWarn(
'injection', 'mode', 'LLM_SECURITY_INJECTION_MODE', 'block'
);
const mode = VALID_MODES.has(resolved) ? resolved : 'block';
// Off mode: skip scanning entirely
if (mode === 'off') {

View file

@ -1,9 +1,12 @@
// audit-trail.mjs — Structured JSONL audit trail writer
// Writes SIEM-ready events to the path specified by LLM_SECURITY_AUDIT_LOG.
// No-op when env var is not set. Zero external dependencies.
// Resolves the audit-log path via getPolicyValueWithEnvWarn so the env-var
// LLM_SECURITY_AUDIT_LOG and policy.json key audit.log_path stay in sync,
// with a one-time deprecation warning when both are explicitly set.
// No-op when neither env nor policy provides a path. Zero external dependencies.
import { appendFileSync, writeFileSync, accessSync, constants } from 'node:fs';
import { dirname } from 'node:path';
import { getPolicyValueWithEnvWarn } from './policy-loader.mjs';
let auditPath = null;
let initialized = false;
@ -16,19 +19,22 @@ function initAuditTrail() {
if (initialized) return auditPath !== null;
initialized = true;
const envPath = process.env.LLM_SECURITY_AUDIT_LOG;
if (!envPath) return false;
// D3 (v7.3.0): env still wins, deprecation warning when policy also set.
const resolved = getPolicyValueWithEnvWarn(
'audit', 'log_path', 'LLM_SECURITY_AUDIT_LOG', null
);
if (!resolved) return false;
try {
// Ensure parent directory exists and is writable
const dir = dirname(envPath);
const dir = dirname(resolved);
accessSync(dir, constants.W_OK);
// Touch file if it doesn't exist
try { accessSync(envPath); } catch { writeFileSync(envPath, ''); }
auditPath = envPath;
try { accessSync(resolved); } catch { writeFileSync(resolved, ''); }
auditPath = resolved;
return true;
} catch (err) {
process.stderr.write(`[llm-security] Audit trail path not writable: ${envPath} (${err.message})\n`);
process.stderr.write(`[llm-security] Audit trail path not writable: ${resolved} (${err.message})\n`);
return false;
}
}

View file

@ -21,6 +21,7 @@ const DEFAULT_POLICY = Object.freeze({
mode: 'warn',
window_size: 20,
long_horizon_window: 100,
escalation_window: 5,
},
secrets: {
additional_patterns: [],
@ -69,6 +70,11 @@ const DEFAULT_POLICY = Object.freeze({
// Cache loaded policy per project root
const cache = new Map();
// Module-scoped Set of env-var names already warned about — dedupes to one
// stderr line per env-var per process, regardless of how many call-sites
// invoke getPolicyValueWithEnvWarn for the same name.
const _warnedEnvVars = new Set();
/**
* Resolve project root from env or cwd.
* @param {string} [explicitRoot]
@ -148,6 +154,57 @@ export function getPolicyValue(section, key, defaultValue, projectRoot) {
return defaultValue;
}
/**
* Resolve a policy value with an overlapping env-var, emitting a one-time
* stderr deprecation warning when both the env-var AND the policy.json key
* are explicitly set.
*
* Resolution order (env-wins is unchanged from getPolicyValue contract):
* 1. If LLM_SECURITY_DEPRECATION_QUIET=1, suppress warning logic entirely
* and return env-value (if defined) else policy-value.
* 2. Otherwise, if env-var is set AND policy-value differs from
* defaultValue (heuristic: user wrote the key in policy.json),
* emit one stderr warning per envVarName per process.
* 3. Return env-value if defined, else policy-value.
*
* Why "differs from defaultValue" rather than parsing the raw policy file:
* loadPolicy() deep-merges DEFAULT_POLICY so `key in policy[section]` is
* always true. Comparing the resolved value to the caller's defaultValue
* is a reliable proxy for "user explicitly overrode this in policy.json"
* because callers pass defaults that match DEFAULT_POLICY.
*
* @param {string} section - Policy section (e.g. 'injection', 'trifecta')
* @param {string} key - Key within section (e.g. 'mode')
* @param {string} envVarName - Overlapping env-var (e.g. 'LLM_SECURITY_INJECTION_MODE')
* @param {*} defaultValue - Hardcoded default (must match DEFAULT_POLICY value)
* @param {string} [projectRoot] - Explicit root
* @returns {*}
*/
export function getPolicyValueWithEnvWarn(section, key, envVarName, defaultValue, projectRoot) {
const envValue = process.env[envVarName];
if (process.env.LLM_SECURITY_DEPRECATION_QUIET === '1') {
if (envValue !== undefined) return envValue;
return getPolicyValue(section, key, defaultValue, projectRoot);
}
const policyValue = getPolicyValue(section, key, defaultValue, projectRoot);
if (envValue !== undefined && policyValue !== defaultValue) {
if (!_warnedEnvVars.has(envVarName)) {
_warnedEnvVars.add(envVarName);
process.stderr.write(
`[llm-security] Deprecation: env-var ${envVarName} will be removed in v8.0.0; ` +
`policy.json key ${section}.${key} also set — env wins for now. ` +
`Suppress with LLM_SECURITY_DEPRECATION_QUIET=1.\n`
);
}
}
if (envValue !== undefined) return envValue;
return policyValue;
}
/**
* Get the full default policy (for documentation/example generation).
* @returns {object}
@ -157,8 +214,9 @@ export function getDefaultPolicy() {
}
/**
* Reset cache (for testing only).
* Reset cache and warning dedup state (for testing only).
*/
export function _resetCacheForTest() {
cache.clear();
_warnedEnvVars.clear();
}

View file

@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadPolicy, getPolicyValue, getDefaultPolicy, _resetCacheForTest } from '../../scanners/lib/policy-loader.mjs';
import { loadPolicy, getPolicyValue, getPolicyValueWithEnvWarn, getDefaultPolicy, _resetCacheForTest } from '../../scanners/lib/policy-loader.mjs';
const TEST_ROOT = join(tmpdir(), `llm-security-policy-test-${Date.now()}`);
const POLICY_DIR = join(TEST_ROOT, '.llm-security');
@ -124,4 +124,114 @@ describe('policy-loader', () => {
assert.equal(policy.ci.failOn, 'critical');
assert.equal(policy.ci.compact, true);
});
it('default policy includes trifecta.escalation_window=5 (D3)', () => {
const defaults = getDefaultPolicy();
assert.equal(defaults.trifecta.escalation_window, 5);
});
});
// ---------------------------------------------------------------------------
// D3: getPolicyValueWithEnvWarn — env-var deprecation warnings
// ---------------------------------------------------------------------------
describe('getPolicyValueWithEnvWarn (D3)', () => {
const ENV_VAR = 'LLM_SECURITY_TEST_DEPRECATED';
const QUIET_VAR = 'LLM_SECURITY_DEPRECATION_QUIET';
let originalWrite;
let stderrCapture;
beforeEach(() => {
_resetCacheForTest();
mkdirSync(POLICY_DIR, { recursive: true });
delete process.env[ENV_VAR];
delete process.env[QUIET_VAR];
stderrCapture = [];
originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...rest) => {
stderrCapture.push(typeof chunk === 'string' ? chunk : chunk.toString());
return true;
};
});
afterEach(() => {
process.stderr.write = originalWrite;
delete process.env[ENV_VAR];
delete process.env[QUIET_VAR];
_resetCacheForTest();
try { rmSync(TEST_ROOT, { recursive: true }); } catch {}
});
it('env wins over policy.json (existing behaviour unchanged)', () => {
writeFileSync(POLICY_FILE, JSON.stringify({
trifecta: { mode: 'block' },
}));
process.env[ENV_VAR] = 'off';
const val = getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
assert.equal(val, 'off');
});
it('returns policy value when env-var is unset', () => {
writeFileSync(POLICY_FILE, JSON.stringify({
trifecta: { mode: 'block' },
}));
const val = getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
assert.equal(val, 'block');
assert.equal(stderrCapture.join(''), ''); // no warning when only policy is set
});
it('returns default when neither env nor policy is set', () => {
rmSync(POLICY_FILE, { force: true });
const val = getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
assert.equal(val, 'warn');
assert.equal(stderrCapture.join(''), '');
});
it('emits one stderr deprecation warning when env+policy both set', () => {
writeFileSync(POLICY_FILE, JSON.stringify({
trifecta: { mode: 'block' },
}));
process.env[ENV_VAR] = 'off';
const val = getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
assert.equal(val, 'off');
const stderr = stderrCapture.join('');
assert.match(stderr, /\[llm-security\] Deprecation: env-var LLM_SECURITY_TEST_DEPRECATED/);
assert.match(stderr, /will be removed in v8\.0\.0/);
assert.match(stderr, /policy\.json key trifecta\.mode also set/);
assert.match(stderr, /Suppress with LLM_SECURITY_DEPRECATION_QUIET=1/);
});
it('warns only once per env-var within the same process', () => {
writeFileSync(POLICY_FILE, JSON.stringify({
trifecta: { mode: 'block' },
}));
process.env[ENV_VAR] = 'off';
getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
const stderr = stderrCapture.join('');
const matches = stderr.match(/\[llm-security\] Deprecation:/g) || [];
assert.equal(matches.length, 1);
});
it('LLM_SECURITY_DEPRECATION_QUIET=1 suppresses warning entirely', () => {
writeFileSync(POLICY_FILE, JSON.stringify({
trifecta: { mode: 'block' },
}));
process.env[ENV_VAR] = 'off';
process.env[QUIET_VAR] = '1';
const val = getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
assert.equal(val, 'off');
assert.equal(stderrCapture.join(''), '');
});
it('does not warn when policy value equals defaultValue (user did not override)', () => {
writeFileSync(POLICY_FILE, JSON.stringify({
trifecta: { mode: 'warn' }, // matches defaultValue
}));
process.env[ENV_VAR] = 'off';
const val = getPolicyValueWithEnvWarn('trifecta', 'mode', ENV_VAR, 'warn', TEST_ROOT);
assert.equal(val, 'off');
assert.equal(stderrCapture.join(''), '');
});
});