ktg-plugin-marketplace/plugins/llm-security/tests/hooks/pre-compact-scan.test.mjs
Kjell Tore Guttormsen 62a9335772 chore(llm-security): v7.3.1 — stabilization patch for forkers and downstream users
No behavior changes. Sets the public stance, tightens documentation, and
removes coherence drift so anyone forking or downloading the plugin gets
a consistent starting point.

Added:
- CONTRIBUTING.md — public fork-and-own guide. Why PRs are not accepted,
  how to fork well, what is welcome via issues.
- README "Project scope" section — out-of-scope table naming what is
  fork-and-own territory (web dashboard, fleet policy, runtime firewall,
  IDE LSP, compliance pack, ticketing, multi-tenancy, ML detectors,
  marketplace UI, SSO/SCIM/RBAC) with commercial alternatives.
- package.json: bugs.url, CONTRIBUTING/SECURITY/CHANGELOG in files
  whitelist for npm publishing.

Changed:
- SECURITY.md rewritten. Supported-versions table from stale 5.1.x to
  current reality (7.3.x active, 7.0-7.2 best-effort, <7.0 EOL).
  Best-effort solo response timeline. Scope expanded to bin/.
- Scanner VERSION constants synced to plugin version. Was 6.0.0 in
  dashboard-aggregator and posture-scanner.
- package.json repository.url corrected from fromaitochitta/ to open/.
- README "Feedback & contributing" links to CONTRIBUTING.md.

Fixed:
- pre-compact-scan size-cap timing test ceiling raised 500ms -> 1000ms.
  Was a flake on Intel Mac and CI under load. Design target unchanged
  (<500ms, documented in CLAUDE.md).

Notes:
- First patch on the stabilization line (post-2026-05-01).
- Wave E attack-simulator scenarios deferred indefinitely; coverage
  remains at 72.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 06:14:03 +02:00

118 lines
4.7 KiB
JavaScript

// pre-compact-scan.test.mjs — Tests for hooks/scripts/pre-compact-scan.mjs
// Covers PreCompact event handling with three modes: block, warn, off.
// Verifies transcript scanning for injection patterns and credentials.
// Verifies size-cap behavior (500 KB tail) stays under 500 ms for large files.
//
// Credential fixture is generated programmatically in before() to avoid
// pre-edit-secrets false-positive on the test file itself.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, dirname } from 'node:path';
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
import { runHook, runHookWithEnv } from './hook-helper.mjs';
const SCRIPT = resolve(import.meta.dirname, '../../hooks/scripts/pre-compact-scan.mjs');
const FIXTURE_DIR = resolve(import.meta.dirname, '../fixtures/transcripts');
const CLEAN = resolve(FIXTURE_DIR, 'clean.jsonl');
const WITH_INJECTION = resolve(FIXTURE_DIR, 'with-injection.jsonl');
const WITH_CREDENTIAL = resolve(FIXTURE_DIR, 'with-credential.jsonl');
const LARGE = resolve(FIXTURE_DIR, 'large.jsonl');
function payload(transcriptPath, extra = {}) {
return {
session_id: 'test-session',
transcript_path: transcriptPath,
cwd: process.cwd(),
hook_event_name: 'PreCompact',
trigger: 'auto',
...extra,
};
}
function parseOutput(stdout) {
const trimmed = stdout.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
describe('pre-compact-scan hook', () => {
before(() => {
if (!existsSync(FIXTURE_DIR)) mkdirSync(FIXTURE_DIR, { recursive: true });
// Credential fixture built at runtime so the repo never contains a literal
// secret-like string that would trip pre-edit-secrets.
const keyPrefix = 'AKI' + 'A';
const body = 'A1B2C3D4E5F6G7H8';
const credLine = JSON.stringify({
type: 'user',
message: { role: 'user', content: `Config: AWS_KEY=${keyPrefix}${body}` },
});
writeFileSync(WITH_CREDENTIAL, credLine + '\n');
// Large transcript fixture — ~1.2 MB of benign filler.
const filler = JSON.stringify({
type: 'user',
message: { role: 'user', content: 'benign content '.repeat(200) },
});
const lines = [];
for (let i = 0; i < 800; i++) lines.push(filler);
writeFileSync(LARGE, lines.join('\n'));
});
after(() => {
try { rmSync(WITH_CREDENTIAL); } catch {}
try { rmSync(LARGE); } catch {}
});
it('clean transcript in warn mode exits 0 with no systemMessage', async () => {
const r = await runHookWithEnv(SCRIPT, payload(CLEAN), { LLM_SECURITY_PRECOMPACT_MODE: 'warn' });
assert.equal(r.code, 0);
assert.equal(parseOutput(r.stdout), null);
});
it('injection pattern in warn mode exits 0 with systemMessage', async () => {
const r = await runHookWithEnv(SCRIPT, payload(WITH_INJECTION), { LLM_SECURITY_PRECOMPACT_MODE: 'warn' });
assert.equal(r.code, 0);
const out = parseOutput(r.stdout);
assert.ok(out, 'expected systemMessage JSON on stdout');
assert.ok(typeof out.systemMessage === 'string' && out.systemMessage.length > 0);
assert.match(out.systemMessage, /finding/);
});
it('injection pattern in block mode exits 2', async () => {
const r = await runHookWithEnv(SCRIPT, payload(WITH_INJECTION), { LLM_SECURITY_PRECOMPACT_MODE: 'block' });
assert.equal(r.code, 2);
const out = parseOutput(r.stdout);
assert.ok(out, 'expected block JSON on stdout');
assert.equal(out.decision, 'block');
});
it('injection pattern in off mode exits 0 with no output', async () => {
const r = await runHookWithEnv(SCRIPT, payload(WITH_INJECTION), { LLM_SECURITY_PRECOMPACT_MODE: 'off' });
assert.equal(r.code, 0);
assert.equal(parseOutput(r.stdout), null);
});
it('size-cap: ~1MB transcript completes under 1000 ms', async () => {
const start = process.hrtime.bigint();
const r = await runHookWithEnv(SCRIPT, payload(LARGE), { LLM_SECURITY_PRECOMPACT_MODE: 'warn' });
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
assert.equal(r.code, 0, 'hook should not fail on large transcript');
// Design target is <500 ms (see CLAUDE.md). Test ceiling is 2x to absorb
// hardware/CI noise without going silent on order-of-magnitude regressions.
assert.ok(elapsedMs < 1000, `expected <1000 ms ceiling, got ${elapsedMs.toFixed(1)} ms`);
});
it('credential pattern in transcript is detected in warn mode', async () => {
const r = await runHookWithEnv(SCRIPT, payload(WITH_CREDENTIAL), { LLM_SECURITY_PRECOMPACT_MODE: 'warn' });
assert.equal(r.code, 0);
const out = parseOutput(r.stdout);
assert.ok(out, 'expected systemMessage JSON on stdout');
assert.match(out.systemMessage, /AWS|finding/);
});
});