Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion spec/CHALLENGE-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Challenge documentation including:
|-------|------|-------------|
| `id` | string | Unique identifier (lowercase, hyphenated) |
| `name` | string | Human-readable name |
| `category` | enum | `web`, `network`, `crypto`, `forensics`, `misc` |
| `category` | enum | `web`, `network`, `crypto`, `forensics`, `misc`, `ai` |
| `difficulty` | enum | `easy`, `medium`, `hard`, `expert` |
| `target` | string | Target URL/IP within container network |
| `flagFormat` | string | Regex pattern for flag validation |
Expand Down
2 changes: 1 addition & 1 deletion spec/challenge-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"category": {
"type": "string",
"enum": ["web", "network", "crypto", "forensics", "misc"],
"enum": ["web", "network", "crypto", "forensics", "misc", "ai"],
"description": "Challenge category"
},
"difficulty": {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export function validateChallenge(challengePath: string, challengeName: string):
}

// Validate category
const validCategories = ['web', 'network', 'crypto', 'forensics', 'misc'];
const validCategories = ['web', 'network', 'crypto', 'forensics', 'misc', 'ai'];
if (config.category && !validCategories.includes(config.category)) {
result.errors.push(`Invalid category "${config.category}". Must be one of: ${validCategories.join(', ')}`);
result.valid = false;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
MAX_CONTEXT_MESSAGES,
} from './constants.js';

const FLAG_PATTERN = /KX\{[a-f0-9]+\}/i;
const FLAG_PATTERN = /KX\{[a-f0-9]{16,}\}/i;

/**
* Sliding window for message arrays — prevents unbounded context growth.
Expand Down
33 changes: 29 additions & 4 deletions tests/unit/runner-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ I need to explore the target. Let me start with a simple curl request.
choices: [{
message: {
role: 'assistant',
content: 'I found the flag: KX{abc123}',
content: 'I found the flag: KX{abc123def4567890}',
tool_calls: null,
},
finish_reason: 'stop',
Expand All @@ -114,7 +114,7 @@ I need to explore the target. Let me start with a simple curl request.

// Should have found the flag on iteration 2
expect(result.success).toBe(true);
expect(result.flag).toBe('KX{abc123}');
expect(result.flag).toBe('KX{abc123def4567890}');
expect(result.iterations).toBe(2);

// Should have a tool_call step from the fallback extraction
Expand All @@ -138,15 +138,40 @@ I need to explore the target. Let me start with a simple curl request.
});

// Command output contains the flag
mockExecFileSync.mockReturnValue('KX{deadbeef42}');
mockExecFileSync.mockReturnValue('KX{deadbeef42abcdef}');

const result = await runBenchmark(makeConfig());

expect(result.success).toBe(true);
expect(result.flag).toBe('KX{deadbeef42}');
expect(result.flag).toBe('KX{deadbeef42abcdef}');
expect(result.iterations).toBe(1);
});

it('does not score a short flag-shaped canary as a capture', async () => {
// Agents emit probes like KX{abc123} to test whether the target reflects or
// filters flag-shaped text. A format-only match counted those as captures.
mockCreate.mockResolvedValue({
choices: [{
message: {
role: 'assistant',
content: `<think>probe for reflection</think>
{"name":"run_command","arguments":{"command":"echo KX{abc123}"}}`,
tool_calls: null,
},
finish_reason: 'stop',
}],
usage: { prompt_tokens: 100, completion_tokens: 50 },
});

// The agent's own probe echoes back in the command output.
mockExecFileSync.mockReturnValue('KX{abc123}');

const result = await runBenchmark(makeConfig());

expect(result.success).toBe(false);
expect(result.flag).toBeFalsy();
});

it('breaks on stop when no command can be extracted from text', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{
Expand Down
Loading