CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
classifier.test.ts372 linesDownload Raw Back to permissions
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';8 9const runSideQueryMock = vi.fn();10const debugLoggerMock = vi.hoisted(() => ({11  debug: vi.fn(),12  warn: vi.fn(),13}));14 15vi.mock('../utils/sideQuery.js', () => ({16  runSideQuery: (...args: unknown[]) => runSideQueryMock(...args),17}));18 19vi.mock('../utils/debugLogger.js', () => ({20  createDebugLogger: () => debugLoggerMock,21}));22 23import {24  classifyAction,25  sanitizeClassifierReason,26  STAGE1_TIMEOUT_MS,27  STAGE2_TIMEOUT_MS,28  type ClassifierInput,29} from './classifier.js';30import type { Config } from '../config/config.js';31import type { ToolRegistry } from '../tools/tool-registry.js';32 33function makeConfig(34  autoModeSettings: ReturnType<Config['getAutoModeSettings']> = {},35): Config {36  return {37    getFastModel: () => 'qwen-turbo-test',38    getModel: () => 'qwen-max-test',39    getAutoModeSettings: () => autoModeSettings,40    getToolRegistry: () =>41      ({ getTool: () => undefined }) as unknown as ToolRegistry,42  } as unknown as Config;43}44 45function makeInput(over: Partial<ClassifierInput> = {}): ClassifierInput {46  return {47    toolName: 'run_shell_command',48    toolParams: { command: 'ls' },49    messages: [],50    config: makeConfig(),51    signal: new AbortController().signal,52    ...over,53  };54}55 56beforeEach(() => {57  runSideQueryMock.mockReset();58  debugLoggerMock.debug.mockReset();59  debugLoggerMock.warn.mockReset();60});61 62afterEach(() => {63  vi.restoreAllMocks();64});65 66describe('classifyAction — stage 1 happy path', () => {67  it('returns allow without calling stage 2 when stage 1 says shouldBlock=false', async () => {68    runSideQueryMock.mockResolvedValueOnce({ shouldBlock: false });69 70    const result = await classifyAction(makeInput());71 72    expect(result.shouldBlock).toBe(false);73    expect(result.reason).toBe('');74    expect(result.unavailable).toBeUndefined();75    expect(result.stage).toBe('fast');76    expect(runSideQueryMock).toHaveBeenCalledTimes(1);77  });78 79  it('passes the fast-stage purpose to sideQuery', async () => {80    runSideQueryMock.mockResolvedValueOnce({ shouldBlock: false });81    await classifyAction(makeInput());82    const call = runSideQueryMock.mock.calls[0]?.[1] as { purpose?: string };83    expect(call?.purpose).toBe('permission_classifier_stage1');84  });85});86 87describe('classifyAction — stage 1 escalates to stage 2', () => {88  it('returns stage 2 verdict (block + reason) when stage 2 confirms block', async () => {89    runSideQueryMock90      .mockResolvedValueOnce({ shouldBlock: true })91      .mockResolvedValueOnce({92        thinking: 'rm -rf / destroys the root filesystem',93        shouldBlock: true,94        reason: 'Irreversible filesystem destruction',95      });96 97    const result = await classifyAction(makeInput());98 99    expect(result.shouldBlock).toBe(true);100    expect(result.reason).toBe('Irreversible filesystem destruction');101    expect(result.thinking).toContain('rm -rf');102    expect(result.unavailable).toBeUndefined();103    expect(result.stage).toBe('thinking');104    expect(runSideQueryMock).toHaveBeenCalledTimes(2);105  });106 107  it('downgrades stage 1 block to allow when stage 2 says shouldBlock=false', async () => {108    runSideQueryMock109      .mockResolvedValueOnce({ shouldBlock: true })110      .mockResolvedValueOnce({111        thinking: 'cleanup of node_modules is consistent with user intent',112        shouldBlock: false,113        reason: 'safe cleanup',114      });115 116    const result = await classifyAction(makeInput());117 118    expect(result.shouldBlock).toBe(false);119    // Allow path discards the reason field.120    expect(result.reason).toBe('');121    expect(result.stage).toBe('thinking');122  });123 124  it('passes the thinking-stage purpose for the second call', async () => {125    runSideQueryMock126      .mockResolvedValueOnce({ shouldBlock: true })127      .mockResolvedValueOnce({ thinking: 't', shouldBlock: false, reason: '' });128    await classifyAction(makeInput());129    const call = runSideQueryMock.mock.calls[1]?.[1] as { purpose?: string };130    expect(call?.purpose).toBe('permission_classifier_stage2');131  });132});133 134describe('classifyAction — fail-closed on stage 1 failure', () => {135  it('returns unavailable=true when stage 1 throws an API error', async () => {136    runSideQueryMock.mockRejectedValueOnce(new Error('API 500'));137    const result = await classifyAction(makeInput());138    expect(result.shouldBlock).toBe(true);139    expect(result.unavailable).toBe(true);140    expect(result.stage).toBe('fast');141    expect(result.reason).toMatch(/blocked for safety/);142  });143 144  it('surfaces a context-overflow reason when stage 1 fails with that error', async () => {145    runSideQueryMock.mockRejectedValueOnce(146      new Error('Prompt is too long: 200000 tokens > 128000 maximum'),147    );148    const result = await classifyAction(makeInput());149    expect(result.shouldBlock).toBe(true);150    expect(result.unavailable).toBe(true);151    expect(result.reason).toMatch(/context window/i);152  });153 154  it('re-throws when the user signal is aborted (not converted to block)', async () => {155    const controller = new AbortController();156    runSideQueryMock.mockImplementationOnce(async () => {157      controller.abort();158      throw new Error('aborted');159    });160    await expect(161      classifyAction(makeInput({ signal: controller.signal })),162    ).rejects.toThrow();163  });164});165 166describe('classifyAction — fail-closed on stage 2 failure', () => {167  it('honors stage 1 block when stage 2 fails (unavailable=true)', async () => {168    runSideQueryMock169      .mockResolvedValueOnce({ shouldBlock: true })170      .mockRejectedValueOnce(new Error('API 500'));171 172    const result = await classifyAction(makeInput());173 174    expect(result.shouldBlock).toBe(true);175    expect(result.unavailable).toBe(true);176    expect(result.stage).toBe('thinking');177    expect(result.reason).toMatch(/Stage 1 flagged/i);178  });179 180  it('re-throws when the user signal aborts during stage 2', async () => {181    const controller = new AbortController();182    runSideQueryMock183      .mockResolvedValueOnce({ shouldBlock: true })184      .mockImplementationOnce(async () => {185        controller.abort();186        throw new Error('aborted');187      });188    await expect(189      classifyAction(makeInput({ signal: controller.signal })),190    ).rejects.toThrow();191  });192});193 194describe('classifier configuration', () => {195  it('uses configured stage timeouts when provided', async () => {196    const timeoutSpy = vi197      .spyOn(AbortSignal, 'timeout')198      .mockImplementation(() => new AbortController().signal);199    runSideQueryMock200      .mockResolvedValueOnce({ shouldBlock: true })201      .mockResolvedValueOnce({ thinking: 't', shouldBlock: false, reason: '' });202 203    await classifyAction(204      makeInput({205        config: makeConfig({206          classifier: {207            timeouts: {208              stage1Ms: 12_345,209              stage2Ms: 67_890,210            },211          },212        }),213      }),214    );215 216    expect(timeoutSpy).toHaveBeenNthCalledWith(1, 12_345);217    expect(timeoutSpy).toHaveBeenNthCalledWith(2, 67_890);218  });219 220  it('falls back when configured stage timeouts are too low', async () => {221    const timeoutSpy = vi222      .spyOn(AbortSignal, 'timeout')223      .mockImplementation(() => new AbortController().signal);224    runSideQueryMock225      .mockResolvedValueOnce({ shouldBlock: true })226      .mockResolvedValueOnce({ thinking: 't', shouldBlock: false, reason: '' });227 228    await classifyAction(229      makeInput({230        config: makeConfig({231          classifier: {232            timeouts: {233              stage1Ms: 1,234              stage2Ms: 999,235            },236          },237        }),238      }),239    );240 241    expect(timeoutSpy).toHaveBeenNthCalledWith(1, STAGE1_TIMEOUT_MS);242    expect(timeoutSpy).toHaveBeenNthCalledWith(2, STAGE2_TIMEOUT_MS);243    expect(debugLoggerMock.warn).toHaveBeenCalledWith(244      `Classifier timeout 1ms below 1000ms floor, using default ${STAGE1_TIMEOUT_MS}ms`,245    );246    expect(debugLoggerMock.warn).toHaveBeenCalledWith(247      `Classifier timeout 999ms below 1000ms floor, using default ${STAGE2_TIMEOUT_MS}ms`,248    );249  });250 251  it('uses temperature 0 and max_output_tokens=32 with thinking disabled for stage 1', async () => {252    runSideQueryMock.mockResolvedValueOnce({ shouldBlock: false });253    await classifyAction(makeInput());254    const opts = runSideQueryMock.mock.calls[0]?.[1] as {255      config?: {256        temperature?: number;257        maxOutputTokens?: number;258        thinkingConfig?: { includeThoughts?: boolean };259      };260    };261    expect(opts.config?.temperature).toBe(0);262    expect(opts.config?.maxOutputTokens).toBe(32);263    expect(opts.config?.thinkingConfig?.includeThoughts).toBe(false);264  });265 266  it('uses max_output_tokens=4096 with thinking disabled for stage 2', async () => {267    runSideQueryMock268      .mockResolvedValueOnce({ shouldBlock: true })269      .mockResolvedValueOnce({ thinking: 't', shouldBlock: false, reason: '' });270    await classifyAction(makeInput());271    const opts = runSideQueryMock.mock.calls[1]?.[1] as {272      config?: {273        maxOutputTokens?: number;274        thinkingConfig?: { includeThoughts?: boolean };275      };276    };277    expect(opts.config?.maxOutputTokens).toBe(4096);278    // Thinking is disabled in every stage (latency-sensitive permission gate).279    expect(opts.config?.thinkingConfig?.includeThoughts).toBe(false);280  });281 282  it('enables API thinking only for stage 2 when configured', async () => {283    runSideQueryMock284      .mockResolvedValueOnce({ shouldBlock: true })285      .mockResolvedValueOnce({ thinking: 't', shouldBlock: false, reason: '' });286 287    await classifyAction(288      makeInput({289        config: makeConfig({290          classifier: {291            thinking: {292              stage2Enabled: true,293            },294          },295        }),296      }),297    );298 299    const stage1 = runSideQueryMock.mock.calls[0]?.[1] as {300      config?: { thinkingConfig?: { includeThoughts?: boolean } };301    };302    const stage2 = runSideQueryMock.mock.calls[1]?.[1] as {303      config?: { thinkingConfig?: { includeThoughts?: boolean } };304    };305 306    expect(stage1.config?.thinkingConfig?.includeThoughts).toBe(false);307    expect(stage2.config?.thinkingConfig?.includeThoughts).toBe(true);308  });309 310  it('does not pin a model — defaults to the fast model via sideQuery', async () => {311    runSideQueryMock.mockResolvedValueOnce({ shouldBlock: false });312    await classifyAction(makeInput());313    const opts = runSideQueryMock.mock.calls[0]?.[1] as { model?: string };314    expect(opts.model).toBeUndefined();315  });316});317 318// Context-overflow detection now delegated to the shared319// `isContextLengthExceededError` utility; tests covering its behavior live320// alongside that module (utils/contextLengthError.test.ts).321 322describe('sanitizeClassifierReason', () => {323  // Security-critical: the classifier reason is LLM-generated and gets324  // interpolated into the main model's tool-error message. A hostile325  // reason can stage a prompt injection if not sanitized.326 327  it('passes empty / falsy through unchanged', () => {328    expect(sanitizeClassifierReason('')).toBe('');329  });330 331  it('strips simple pseudo-tags like <system>...</system>', () => {332    expect(sanitizeClassifierReason('safe <system>danger</system> tail')).toBe(333      'safe danger tail',334    );335  });336 337  it('iterates strip until stable — no complete <...> tag can survive', () => {338    // The threat is a pseudo-tag like `<system>...` confusing the339    // downstream model. A single /<[^>]*>/g pass on a nested input340    // like `<scr<script>extra>` leaves `>` orphaned tokens which is341    // fine — what must NOT survive is any complete `<...>` pair.342    const result = sanitizeClassifierReason('<scr<script>extra>payload');343    expect(result).not.toMatch(/<[^>]*>/);344  });345 346  it('bounds iteration so adversarial inputs cannot create unbounded work', () => {347    // 8-iteration cap means the function is O(n) regardless of how the348    // attacker structures the input. Even a degenerate string with many349    // overlapping tags terminates promptly.350    const adversarial = '<a'.repeat(2000) + '>'.repeat(2000);351    const t0 = Date.now();352    sanitizeClassifierReason(adversarial);353    expect(Date.now() - t0).toBeLessThan(1000);354  });355 356  it('collapses whitespace and newlines to single spaces', () => {357    expect(sanitizeClassifierReason('line1\nline2\n\n\nline3')).toBe(358      'line1 line2 line3',359    );360  });361 362  it('hard-caps length at 200 characters', () => {363    expect(sanitizeClassifierReason('a'.repeat(500)).length).toBe(200);364  });365 366  it('trims surrounding whitespace after collapse', () => {367    expect(sanitizeClassifierReason('   leading  trailing   ')).toBe(368      'leading trailing',369    );370  });371});372 
basant307/AI_Governance_Project · CoolFace