BREAKING CHANGE: the four LLM_SECURITY_* configuration env-vars deprecated in v7.3.0 are removed. .llm-security/policy.json is now the only source: LLM_SECURITY_INJECTION_MODE -> injection.mode LLM_SECURITY_TRIFECTA_MODE -> trifecta.mode LLM_SECURITY_ESCALATION_WINDOW -> trifecta.escalation_window LLM_SECURITY_AUDIT_LOG -> audit.log_path LLM_SECURITY_DEPRECATION_QUIET -> dies with the mechanism it silenced Setting a removed var is now inert - it does not warn, and it does not configure. Env-vars with no policy equivalent (PRECOMPACT_MODE, PRECOMPACT_MAX_BYTES, UPDATE_CHECK, MCP_CACHE_FILE, IDE_ROOTS) are unaffected. getPolicyValueWithEnvWarn and its one-shot stderr warning are deleted from policy-loader.mjs, along with the module-scoped warned-var Set. The four call sites collapse to getPolicyValue. getPolicyValue's JSDoc claimed "environment variables ALWAYS take precedence" - it never read env itself, so that line described the shim, and it is corrected rather than deleted. User-facing hook strings that advertised a removed var as the escape hatch now name the policy key instead: the inject-scan block reason, its warn-mode note, both escalation-window advisories, and the trifecta block message. A blocked user following the old text would have set a var that does nothing. Tests. tests/lib/v8-env-removal.test.mjs is the regression gate and was written failing first (8 of 12 red before the change). It pins the NEGATIVE - setting a removed var does not alter the outcome - because that is the half that rots silently: a re-introduced process.env read would leave every migrated positive test green, since those configure through policy.json and never set the var at all. One assertion walks hooks/scripts and scanners for `process.env.<removed>` so the re-introduction is caught structurally, not only behaviourally. The 44 env-driven test occurrences (18 inject-scan, 13+4 session-guard, 9 audit-trail) migrate to a throwaway .llm-security/policy.json via a new runHookWithPolicy helper in hook-helper.mjs; audit-trail runs in-process, so it supplies the same policy through CLAUDE_PROJECT_ROOT. The D3 mechanism tests in policy-loader.test.mjs are deleted with the mechanism. Suite 2039 tests, 2037 pass (+12 gate, -7 D3 mechanism). The 2 failures are the known parallel-load timing flakes (pre-compact-scan size-cap, pre-install-supply-chain F-3); both green when run isolated. Remaining in Phase 3: posture-scanner TRIFECTA_MODE heuristic, riskScoreV1 removal, ghost-var cleanup, docs + migration note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
137 lines
4.9 KiB
JavaScript
137 lines
4.9 KiB
JavaScript
// audit-trail.test.mjs — Tests for structured JSONL audit trail
|
|
// v8.0.0: configured via policy.json `audit.log_path`, not LLM_SECURITY_AUDIT_LOG.
|
|
|
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { writeAuditEvent, isAuditEnabled, _resetForTest } from '../../scanners/lib/audit-trail.mjs';
|
|
import { _resetCacheForTest } from '../../scanners/lib/policy-loader.mjs';
|
|
|
|
const TEST_LOG = join(tmpdir(), `llm-security-audit-test-${Date.now()}.jsonl`);
|
|
|
|
// v8.0.0: the audit-log path comes from the policy.json key `audit.log_path`
|
|
// (LLM_SECURITY_AUDIT_LOG was removed). audit-trail runs in-process, so the
|
|
// policy is supplied through a throwaway CLAUDE_PROJECT_ROOT.
|
|
let projectRoot = null;
|
|
|
|
/** Point audit.log_path at `logPath` for the next writeAuditEvent/isAuditEnabled. */
|
|
function enableAudit(logPath) {
|
|
writeFileSync(
|
|
join(projectRoot, '.llm-security', 'policy.json'),
|
|
JSON.stringify({ audit: { log_path: logPath } })
|
|
);
|
|
_resetCacheForTest();
|
|
_resetForTest();
|
|
}
|
|
|
|
/** Leave audit.log_path unset (the default). */
|
|
function disableAudit() {
|
|
writeFileSync(join(projectRoot, '.llm-security', 'policy.json'), JSON.stringify({}));
|
|
_resetCacheForTest();
|
|
_resetForTest();
|
|
}
|
|
|
|
describe('audit-trail', () => {
|
|
beforeEach(() => {
|
|
projectRoot = mkdtempSync(join(tmpdir(), 'llmsec-audit-root-'));
|
|
mkdirSync(join(projectRoot, '.llm-security'), { recursive: true });
|
|
process.env.CLAUDE_PROJECT_ROOT = projectRoot;
|
|
disableAudit();
|
|
// Clean up test file
|
|
try { unlinkSync(TEST_LOG); } catch {}
|
|
});
|
|
|
|
afterEach(() => {
|
|
_resetForTest();
|
|
_resetCacheForTest();
|
|
delete process.env.CLAUDE_PROJECT_ROOT;
|
|
if (projectRoot) rmSync(projectRoot, { recursive: true, force: true });
|
|
projectRoot = null;
|
|
try { unlinkSync(TEST_LOG); } catch {}
|
|
});
|
|
|
|
it('is disabled when audit.log_path is not set', () => {
|
|
assert.equal(isAuditEnabled(), false);
|
|
});
|
|
|
|
it('is enabled when audit.log_path is a writable path', () => {
|
|
enableAudit(TEST_LOG);
|
|
assert.equal(isAuditEnabled(), true);
|
|
});
|
|
|
|
it('no-op when audit.log_path is not set', () => {
|
|
writeAuditEvent({ event_type: 'test', severity: 'info', source: 'test' });
|
|
assert.equal(existsSync(TEST_LOG), false);
|
|
});
|
|
|
|
it('writes valid JSONL when enabled', () => {
|
|
enableAudit(TEST_LOG);
|
|
writeAuditEvent({
|
|
event_type: 'trifecta_warning',
|
|
severity: 'high',
|
|
source: 'post-session-guard',
|
|
details: { window_size: 20 },
|
|
owasp: ['ASI01', 'ASI02'],
|
|
action_taken: 'warned',
|
|
});
|
|
|
|
const content = readFileSync(TEST_LOG, 'utf8').trim();
|
|
const entry = JSON.parse(content);
|
|
|
|
assert.equal(entry.event_type, 'trifecta_warning');
|
|
assert.equal(entry.severity, 'high');
|
|
assert.equal(entry.source, 'post-session-guard');
|
|
assert.deepEqual(entry.owasp, ['ASI01', 'ASI02']);
|
|
assert.equal(entry.action_taken, 'warned');
|
|
assert.ok(entry.timestamp.match(/^\d{4}-\d{2}-\d{2}T/), 'Expected ISO timestamp');
|
|
assert.ok(entry.session_id, 'Expected session_id');
|
|
});
|
|
|
|
it('appends multiple events as separate lines', () => {
|
|
enableAudit(TEST_LOG);
|
|
writeAuditEvent({ event_type: 'event1', severity: 'info', source: 'test' });
|
|
writeAuditEvent({ event_type: 'event2', severity: 'medium', source: 'test' });
|
|
writeAuditEvent({ event_type: 'event3', severity: 'high', source: 'test' });
|
|
|
|
const lines = readFileSync(TEST_LOG, 'utf8').trim().split('\n');
|
|
assert.equal(lines.length, 3);
|
|
|
|
const e1 = JSON.parse(lines[0]);
|
|
const e3 = JSON.parse(lines[2]);
|
|
assert.equal(e1.event_type, 'event1');
|
|
assert.equal(e3.event_type, 'event3');
|
|
});
|
|
|
|
it('events contain all required fields', () => {
|
|
enableAudit(TEST_LOG);
|
|
writeAuditEvent({ event_type: 'test', severity: 'info', source: 'test-hook' });
|
|
|
|
const entry = JSON.parse(readFileSync(TEST_LOG, 'utf8').trim());
|
|
const required = ['timestamp', 'session_id', 'event_type', 'severity', 'source', 'details', 'owasp', 'action_taken'];
|
|
for (const field of required) {
|
|
assert.ok(field in entry, `Missing required field: ${field}`);
|
|
}
|
|
});
|
|
|
|
it('provides defaults for optional fields', () => {
|
|
enableAudit(TEST_LOG);
|
|
writeAuditEvent({ event_type: 'minimal' });
|
|
|
|
const entry = JSON.parse(readFileSync(TEST_LOG, 'utf8').trim());
|
|
assert.equal(entry.severity, 'info');
|
|
assert.equal(entry.source, 'unknown');
|
|
assert.deepEqual(entry.details, {});
|
|
assert.deepEqual(entry.owasp, []);
|
|
assert.equal(entry.action_taken, 'warned');
|
|
});
|
|
|
|
it('does not crash on invalid path', () => {
|
|
enableAudit('/nonexistent/dir/audit.jsonl');
|
|
// Should not throw — gracefully logs to stderr
|
|
assert.doesNotThrow(() => {
|
|
writeAuditEvent({ event_type: 'test', severity: 'info', source: 'test' });
|
|
});
|
|
});
|
|
});
|