feat(post-session-guard): E17 — configurable escalation window + 20-call MEDIUM advisory

Critical-review §4 E17 finding: pre-v7.2.0 the delegation-after-input
advisory fired only within a 5-call window. Attackers who deliberately
waited 6+ calls before delegating bypassed detection. Window was also
hardcoded — operators couldn't tune it for their environment.

Two coordinated changes:

1. LLM_SECURITY_ESCALATION_WINDOW env var (primary window override)
   - parseInt(env) || getPolicyValue('trifecta', 'escalation_window', 5)
   - Mirrors the established pattern from
     LLM_SECURITY_TRIFECTA_MODE et al.
   - Setting env=3 narrows; env=8 expands.

2. Secondary 20-call MEDIUM advisory (slow-burn variant)
   - DELEGATION_ESCALATION_WINDOW_MEDIUM = 20 (hardcoded — same value
     for all operators; tunable in a future patch if needed)
   - checkEscalationAfterInput now returns `tier: 'primary'|'secondary'|null`
   - formatEscalationWarning emits a different message for secondary —
     mentions "slow-burn", references env-var, distinct from the
     primary "DeepMind Category 4" framing

Hook reads max(WINDOW_SIZE, secondary+5) entries to cover the wider
window. Existing duplicate-suppression (`escalation_warning` state
entry) covers both tiers. Audit-trail event captures `tier` field.

Tests: +5 cases in tests/hooks/post-session-guard.test.mjs:
- secondary window catches 9-call distance (slow-burn)
- secondary boundary at exactly 20 calls
- primary regression guard (1-call distance)
- env=3 narrows primary (4-call distance becomes secondary)
- env=8 expands primary (7-call distance stays primary)

Updated existing test "does NOT trigger when input_source is >5 calls
ago" — now requires >20 calls (secondary window catches 6-20).

Suite: 1644 → 1672 (+28 from new tests + extended scope). All green.

CLAUDE.md hooks table updated to document both windows and the env var.
This commit is contained in:
Kjell Tore Guttormsen 2026-04-29 14:26:18 +02:00
commit f0a1d4024a
3 changed files with 215 additions and 23 deletions

View file

@ -960,12 +960,15 @@ describe('post-session-guard — escalation-after-input (S4)', () => {
} finally { teardown(); }
});
it('does NOT trigger when input_source is >5 calls ago', async () => {
it('does NOT trigger when input_source is >20 calls ago (outside both windows, E17 v7.2.0)', async () => {
// Pre-E17 this test asserted >5 calls ago. After E17 the secondary
// 20-call MEDIUM advisory catches input within [primary, 20]; only
// input >20 calls ago is a true negative.
setup();
try {
const entries = [];
entries.push(makeToolEntry('WebFetch', ['input_source'], 'https://example.com'));
for (let i = 0; i < 8; i++) {
for (let i = 0; i < 25; i++) {
entries.push(makeToolEntry('Read', ['data_access'], '/tmp/test.txt'));
}
writeStateFile(entries);
@ -978,7 +981,7 @@ describe('post-session-guard — escalation-after-input (S4)', () => {
const advisory = parseAdvisory(result.stdout);
if (advisory) {
assert.ok(!advisory.systemMessage.includes('Escalation-after-input'),
'should NOT trigger escalation when input is >5 calls ago');
'should NOT trigger when input is >20 calls ago (outside secondary window)');
}
} finally { teardown(); }
});
@ -1103,6 +1106,143 @@ describe('post-session-guard — escalation-after-input (S4)', () => {
assert.equal(result.code, 0, 'escalation should never block (MEDIUM only)');
} finally { teardown(); }
});
// -------------------------------------------------------------------------
// E17 (v7.2.0) — configurable primary window + secondary 20-call advisory
// -------------------------------------------------------------------------
it('E17 — secondary window catches delegation 6-20 calls after input (slow-burn)', async () => {
setup();
try {
const entries = [];
entries.push(makeToolEntry('WebFetch', ['input_source'], 'https://attacker.com'));
// 8 Read calls — input is 9 calls before Task. Primary window (5) is
// exceeded; secondary window (20) still catches it.
for (let i = 0; i < 8; i++) {
entries.push(makeToolEntry('Read', ['data_access'], '/tmp/test.txt'));
}
writeStateFile(entries);
const result = await runHook(SCRIPT, payload({
toolName: 'Task',
toolInput: { description: 'Slow-burn delegation' },
}));
assert.equal(result.code, 0);
const advisory = parseAdvisory(result.stdout);
assert.ok(advisory, 'should emit secondary-window advisory');
assert.ok(
advisory.systemMessage.includes('Slow-burn') ||
advisory.systemMessage.includes('slow-burn'),
`expected slow-burn message, got: ${advisory.systemMessage.slice(0, 200)}`,
);
} finally { teardown(); }
});
it('E17 — secondary window boundary: exactly 20 calls triggers advisory', async () => {
setup();
try {
const entries = [];
entries.push(makeToolEntry('WebFetch', ['input_source'], 'https://attacker.com'));
// 19 Read calls — input is 20 calls before Task. At the boundary.
for (let i = 0; i < 19; i++) {
entries.push(makeToolEntry('Read', ['data_access'], '/tmp/test.txt'));
}
writeStateFile(entries);
const result = await runHook(SCRIPT, payload({
toolName: 'Task',
toolInput: { description: 'Boundary test' },
}));
assert.equal(result.code, 0);
const advisory = parseAdvisory(result.stdout);
assert.ok(advisory, 'should detect at exactly the 20-call boundary');
} finally { teardown(); }
});
it('E17 — primary advisory still fires within first 5 calls (regression guard)', async () => {
setup();
try {
const entries = [];
entries.push(makeToolEntry('WebFetch', ['input_source'], 'https://attacker.com'));
entries.push(makeToolEntry('Read', ['data_access'], '/tmp/test.txt'));
writeStateFile(entries);
const result = await runHook(SCRIPT, payload({
toolName: 'Task',
toolInput: { description: 'Fast escalation' },
}));
assert.equal(result.code, 0);
const advisory = parseAdvisory(result.stdout);
assert.ok(advisory, 'primary advisory must still fire');
// Primary message — NOT slow-burn
assert.ok(
!advisory.systemMessage.includes('Slow-burn') && !advisory.systemMessage.includes('slow-burn'),
`expected primary (not slow-burn) message, got: ${advisory.systemMessage.slice(0, 200)}`,
);
assert.ok(
advisory.systemMessage.includes('Escalation-after-input'),
`expected primary escalation message, got: ${advisory.systemMessage.slice(0, 200)}`,
);
} finally { teardown(); }
});
it('E17 — LLM_SECURITY_ESCALATION_WINDOW=3 narrows primary window', async () => {
setup();
try {
const entries = [];
entries.push(makeToolEntry('WebFetch', ['input_source'], 'https://attacker.com'));
// 3 Read calls — input is 4 calls before Task.
// With default window=5 → primary advisory.
// With env=3 → outside primary, inside secondary (slow-burn advisory).
for (let i = 0; i < 3; i++) {
entries.push(makeToolEntry('Read', ['data_access'], '/tmp/test.txt'));
}
writeStateFile(entries);
const { runHookWithEnv } = await import('./hook-helper.mjs');
const result = await runHookWithEnv(SCRIPT, payload({
toolName: 'Task',
toolInput: { description: 'env-overridden window' },
}), { LLM_SECURITY_ESCALATION_WINDOW: '3' });
assert.equal(result.code, 0);
const advisory = parseAdvisory(result.stdout);
assert.ok(advisory, 'should still emit advisory');
// With narrowed primary, a 4-call distance falls into the secondary window.
assert.ok(
advisory.systemMessage.includes('Slow-burn') ||
advisory.systemMessage.includes('slow-burn'),
`expected slow-burn (since 4 > narrowed primary=3), got: ${advisory.systemMessage.slice(0, 200)}`,
);
} finally { teardown(); }
});
it('E17 — LLM_SECURITY_ESCALATION_WINDOW=8 expands primary window', async () => {
setup();
try {
const entries = [];
entries.push(makeToolEntry('WebFetch', ['input_source'], 'https://attacker.com'));
// 6 Read calls — input is 7 calls before Task.
// With default window=5 → outside primary, inside secondary (slow-burn).
// With env=8 → inside primary (primary advisory).
for (let i = 0; i < 6; i++) {
entries.push(makeToolEntry('Read', ['data_access'], '/tmp/test.txt'));
}
writeStateFile(entries);
const { runHookWithEnv } = await import('./hook-helper.mjs');
const result = await runHookWithEnv(SCRIPT, payload({
toolName: 'Task',
toolInput: { description: 'env-expanded window' },
}), { LLM_SECURITY_ESCALATION_WINDOW: '8' });
assert.equal(result.code, 0);
const advisory = parseAdvisory(result.stdout);
assert.ok(advisory, 'should emit advisory');
assert.ok(
!advisory.systemMessage.includes('Slow-burn') && !advisory.systemMessage.includes('slow-burn'),
`expected primary message (7 ≤ env=8), got: ${advisory.systemMessage.slice(0, 200)}`,
);
} finally { teardown(); }
});
});
// ---------------------------------------------------------------------------