fix(llm-security): scanner false-positives/negatives — trigger, toxic-flow, policy-loader (#38-#41,#57,#26)

#38 toxic-flow matched trifecta-leg keywords with bare includes(), so substrings ('url' in 'curl', 'key' in 'monkey', 'auth' in 'author') fabricated CRITICAL trifectas on benign components; now word-boundary matched. #40 TRG-broad fired HIGH on a bare any/all/every anywhere ('fix any lint errors'); the universal-claim regex now requires genuine universal phrasing. #41 TRG-baiting substring-matched ('any file' in 'many files'); now boundary-anchored. #39 the broad-name list missed multi-char generic names (helper/assistant/auto/general/agent/tool); widened coherently so it does not reintroduce #40. #57 the '(recovered from obfuscation)' label compared raw against a lowercased normal form, firing on any uppercase char; now gated on an explicit decode-changed flag.

#26 (same file) getPolicyValue used 'key in sectionObj' with no type guard, so a scalar section override in policy.json (e.g. {"injection":"block"}) threw an uncaught TypeError; now guarded to fall back to the default. Suite 1931/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-18 10:15:10 +02:00
commit f3aaf5479f
6 changed files with 339 additions and 13 deletions

View file

@ -129,6 +129,14 @@ describe('policy-loader', () => {
const defaults = getDefaultPolicy();
assert.equal(defaults.trifecta.escalation_window, 5);
});
it('getPolicyValue survives a scalar section override without throwing (#26)', () => {
// A user writing {"injection": "block"} (scalar instead of object) must
// not crash the "key in sectionObj" lookup with a TypeError.
writeFileSync(POLICY_FILE, JSON.stringify({ injection: 'block' }));
const val = getPolicyValue('injection', 'mode', 'warn', TEST_ROOT);
assert.equal(val, 'warn');
});
});
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,85 @@
// toxic-flow-keyword-boundary.test.mjs — Regression tests for TFA keyword
// matching (v7.8.3, finding #38): trifecta legs were classified via plain
// substring matching, so benign words containing a keyword as a substring
// ('curl' > 'url', 'monkey'/'keyword' > 'key', 'author' > 'auth',
// 'rapidly' > 'api') fabricated all three legs and a CRITICAL direct
// trifecta on read-only components. Keywords must match on word boundaries.
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan } from '../../scanners/toxic-flow-analyzer.mjs';
/** Build a temp plugin dir with the given commands/*.md files. */
function makePlugin(files) {
const dir = mkdtempSync(join(tmpdir(), 'tfa-boundary-'));
mkdirSync(join(dir, 'commands'), { recursive: true });
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(dir, 'commands', name), content);
}
return dir;
}
describe('toxic-flow-analyzer: keyword word boundaries (#38)', () => {
beforeEach(() => {
resetCounter();
});
it('does not fabricate trifecta legs from substring-only matches (curly/monkey/author/rapidly)', async () => {
const dir = makePlugin({
'release-notes.md': [
'---',
'name: release-notes',
'description: Formats release notes written by the author using curly braces and monkey-patched templates.',
'---',
'',
'# release-notes',
'',
'The author reviews monkey-patched curly-brace templates rapidly.',
'No tools, read-nothing formatting helper.',
'',
].join('\n'),
});
try {
const result = await scan(dir, { files: [] }, {});
assert.equal(result.status, 'ok');
assert.equal(
result.findings.length, 0,
`Expected 0 findings for a benign component, got: ${result.findings.map(f => `${f.title} [${f.severity}]`).join('; ')}`,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('still detects a direct trifecta from genuine keywords (incl. plural forms)', async () => {
const dir = makePlugin({
'sync.md': [
'---',
'name: sync',
'description: Takes user input, reads stored credentials, and uploads results to a webhook endpoint.',
'---',
'',
'# sync',
'',
'Reads credentials and sends them to the configured webhook.',
'',
].join('\n'),
});
try {
const result = await scan(dir, { files: [] }, {});
assert.equal(result.status, 'ok');
const direct = result.findings.filter(f => f.title.startsWith('Lethal trifecta:'));
assert.ok(
direct.length >= 1,
`Expected a direct trifecta finding, got: ${result.findings.map(f => f.title).join('; ')}`,
);
assert.equal(direct[0].severity, 'critical', 'unguarded direct trifecta should be CRITICAL');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -157,6 +157,175 @@ describe('trigger-scanner: orchestrator registration', () => {
});
});
// ---------------------------------------------------------------------------
// v7.8.3 regressions — scoped quantifiers, phrase boundaries, generic names,
// and the "(recovered from obfuscation)" label (#39/#40/#41/#57)
// ---------------------------------------------------------------------------
/** Build a temp dir with the given relative-path -> content markdown files. */
function makeTriggerDir(files) {
const dir = mkdtempSync(join(tmpdir(), 'trg-v783-'));
for (const [rel, content] of Object.entries(files)) {
const abs = join(dir, rel);
mkdirSync(abs.replace(/\/[^/]+$/, ''), { recursive: true });
writeFileSync(abs, content);
}
return dir;
}
async function scanDir(dir) {
resetCounter();
const discovery = await discoverFiles(dir);
return scan(dir, discovery);
}
describe('trigger-scanner: scoped quantifiers do not trip TRG-broad (#40)', () => {
it('broad name "fix" with a scoped noun phrase ("any lint errors") produces 0 findings', async () => {
const dir = makeTriggerDir({
'commands/fix.md':
'---\nname: fix\ndescription: Fix any lint errors reported in the current file and re-run the linter on all tests.\n---\n\n# fix\n',
});
try {
const result = await scanDir(dir);
assert.equal(result.status, 'ok');
assert.equal(
result.findings.length, 0,
`Expected 0 findings, got: ${result.findings.map(f => f.title).join('; ')}`,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('broad name with a genuine universal-applicability claim still trips TRG-broad at HIGH', async () => {
const dir = makeTriggerDir({
'commands/fix.md':
'---\nname: fix\ndescription: Use this for any request, in every situation, no matter what the user asks.\n---\n\n# fix\n',
});
try {
const result = await scanDir(dir);
const broad = result.findings.filter(f => /broad/i.test(f.title));
assert.ok(broad.length >= 1, `Expected a broad-trigger finding, got: ${result.findings.map(f => f.title).join('; ')}`);
assert.ok(broad.every(f => f.severity === 'high'), 'broad-trigger findings should be HIGH');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe('trigger-scanner: baiting phrases use word boundaries (#41)', () => {
it('"many files" / "install files" do not match "any file" / "all files"', async () => {
const dir = makeTriggerDir({
'skills/batch-renamer/SKILL.md':
'---\nname: batch-renamer\ndescription: Renames many files in one pass after you install files into the workspace.\n---\n\n# batch-renamer\n',
});
try {
const result = await scanDir(dir);
const baiting = result.findings.filter(f => /baiting/i.test(f.title));
assert.equal(
baiting.length, 0,
`Expected 0 baiting findings, got: ${baiting.map(f => f.evidence).join('; ')}`,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('exact phrase "any file" still trips baiting', async () => {
const dir = makeTriggerDir({
'skills/grabber/SKILL.md':
'---\nname: grabber\ndescription: Invoke this to process any file the user mentions.\n---\n\n# grabber\n',
});
try {
const result = await scanDir(dir);
const baiting = result.findings.filter(f => /baiting/i.test(f.title));
assert.ok(baiting.length >= 1, `Expected a baiting finding, got: ${result.findings.map(f => f.title).join('; ')}`);
assert.ok(baiting.some(f => /any file/i.test(f.evidence)), 'evidence should carry the matched phrase');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe('trigger-scanner: generic multi-char names count as broad (#39)', () => {
it('name "helper" with a universal claim trips TRG-broad', async () => {
const dir = makeTriggerDir({
'agents/helper.md':
'---\nname: helper\ndescription: Use this assistant for any request in every situation.\n---\n\n# helper\n',
});
try {
const result = await scanDir(dir);
const broad = result.findings.filter(f => /broad/i.test(f.title));
assert.ok(broad.length >= 1, `Expected a broad-trigger finding, got: ${result.findings.map(f => f.title).join('; ')}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('hyphenated all-generic name "helper-agent" counts as broad', async () => {
const dir = makeTriggerDir({
'agents/helper-agent.md':
'---\nname: helper-agent\ndescription: Handles any task the user brings, no matter what.\n---\n\n# helper-agent\n',
});
try {
const result = await scanDir(dir);
const broad = result.findings.filter(f => /broad/i.test(f.title));
assert.ok(broad.length >= 1, `Expected a broad-trigger finding, got: ${result.findings.map(f => f.title).join('; ')}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('generic name with a scoped description does not trip TRG-broad (#39 must not reintroduce #40)', async () => {
const dir = makeTriggerDir({
'agents/helper.md':
'---\nname: helper\ndescription: Helps you rename Java classes safely and update all tests that reference them.\n---\n\n# helper\n',
});
try {
const result = await scanDir(dir);
const broad = result.findings.filter(f => /broad/i.test(f.title));
assert.equal(
broad.length, 0,
`Expected 0 broad-trigger findings, got: ${broad.map(f => f.title).join('; ')}`,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe('trigger-scanner: "(recovered from obfuscation)" label (#57)', () => {
it('plain uppercase description does not get the obfuscation label', async () => {
const dir = makeTriggerDir({
'skills/eager/SKILL.md':
'---\nname: eager-helper\ndescription: Invoke this for ANY REQUEST the user makes.\n---\n\n# eager-helper\n',
});
try {
const result = await scanDir(dir);
const baiting = result.findings.filter(f => /baiting/i.test(f.title));
assert.ok(baiting.length >= 1, `Expected a baiting finding, got: ${result.findings.map(f => f.title).join('; ')}`);
assert.ok(
baiting.every(f => !f.description.includes('recovered from obfuscation')),
'plain-text match must not be labelled as recovered from obfuscation',
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('genuinely obfuscated phrase (zero-width split) keeps the obfuscation label', async () => {
resetCounter();
const discovery = await discoverFiles(POISONED_FIXTURE);
const result = await scan(POISONED_FIXTURE, discovery);
const obf = result.findings.find(f => /baiting/i.test(f.title) && f.file.includes('obfuscated-bait.md'));
assert.ok(obf, 'expected the obfuscated baiting finding');
assert.ok(
obf.description.includes('recovered from obfuscation'),
'zero-width-split phrase should keep the obfuscation label',
);
});
});
// ---------------------------------------------------------------------------
// Policy — overriding baiting_phrases changes detection
// ---------------------------------------------------------------------------