icoa-cli 2.19.99 → 2.19.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/commands/ai4ctf.js +1 -700
  2. package/dist/commands/connect.js +1 -66
  3. package/dist/commands/ctf.js +1 -620
  4. package/dist/commands/ctf4ai-demo.js +1 -525
  5. package/dist/commands/env.js +1 -737
  6. package/dist/commands/exam.js +1 -2353
  7. package/dist/commands/files.js +1 -52
  8. package/dist/commands/hint.js +1 -119
  9. package/dist/commands/lang.js +1 -155
  10. package/dist/commands/log.js +1 -163
  11. package/dist/commands/note.js +1 -32
  12. package/dist/commands/ref.js +1 -63
  13. package/dist/commands/setup.js +1 -103
  14. package/dist/commands/shell.js +1 -55
  15. package/dist/commands/theme.js +1 -50
  16. package/dist/index.js +1 -225
  17. package/dist/lib/access.js +1 -246
  18. package/dist/lib/budget.js +1 -42
  19. package/dist/lib/colors.js +1 -21
  20. package/dist/lib/config.js +1 -60
  21. package/dist/lib/ctfd-client.js +1 -274
  22. package/dist/lib/demo-exam.js +1 -249
  23. package/dist/lib/demo-flags.js +1 -27
  24. package/dist/lib/demo-stats.js +1 -65
  25. package/dist/lib/exam-client.js +1 -57
  26. package/dist/lib/exam-setup.js +1 -23
  27. package/dist/lib/exam-state.js +1 -112
  28. package/dist/lib/gemini.js +1 -235
  29. package/dist/lib/i18n.js +1 -273
  30. package/dist/lib/log-sync.js +1 -110
  31. package/dist/lib/logger.js +1 -59
  32. package/dist/lib/paper-upgrade.js +1 -117
  33. package/dist/lib/platform.js +1 -86
  34. package/dist/lib/sandbox.js +1 -93
  35. package/dist/lib/terminal.js +1 -49
  36. package/dist/lib/theme.js +1 -108
  37. package/dist/lib/translation.js +1 -66
  38. package/dist/lib/ui.js +1 -80
  39. package/dist/lib/update-check.js +1 -102
  40. package/dist/postinstall.js +1 -48
  41. package/dist/repl.js +1 -1259
  42. package/dist/types/index.d.ts +1 -1
  43. package/dist/types/index.js +1 -38
  44. package/package.json +6 -2
  45. package/translations/sw/i18n-snippet.ts +1 -0
@@ -1,65 +1 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { getIcoaDir } from './config.js';
4
- const STATS_FILE = () => join(getIcoaDir(), 'demo-stats.json');
5
- const RETRY_FILE = () => join(getIcoaDir(), 'demo-retry.json');
6
- const DEFAULT_STATS = {
7
- best: 0,
8
- bestPercentage: 0,
9
- attempts: 0,
10
- lastFive: [],
11
- };
12
- export function getDemoStats() {
13
- try {
14
- if (!existsSync(STATS_FILE()))
15
- return { ...DEFAULT_STATS };
16
- const data = JSON.parse(readFileSync(STATS_FILE(), 'utf-8'));
17
- return { ...DEFAULT_STATS, ...data };
18
- }
19
- catch {
20
- return { ...DEFAULT_STATS };
21
- }
22
- }
23
- export function recordDemoAttempt(score, total) {
24
- const current = getDemoStats();
25
- const pct = total > 0 ? Math.round((score / total) * 100) : 0;
26
- const next = {
27
- best: Math.max(current.best, score),
28
- bestPercentage: Math.max(current.bestPercentage, pct),
29
- attempts: current.attempts + 1,
30
- lastFive: [
31
- { score, total, at: new Date().toISOString() },
32
- ...current.lastFive,
33
- ].slice(0, 5),
34
- };
35
- try {
36
- writeFileSync(STATS_FILE(), JSON.stringify(next, null, 2));
37
- }
38
- catch { }
39
- return next;
40
- }
41
- export function saveRetryQueue(questions) {
42
- try {
43
- writeFileSync(RETRY_FILE(), JSON.stringify({ questions, savedAt: new Date().toISOString() }, null, 2));
44
- }
45
- catch { }
46
- }
47
- export function getRetryQueue() {
48
- try {
49
- if (!existsSync(RETRY_FILE()))
50
- return null;
51
- const data = JSON.parse(readFileSync(RETRY_FILE(), 'utf-8'));
52
- if (Array.isArray(data.questions) && data.questions.length > 0) {
53
- return data.questions;
54
- }
55
- }
56
- catch { }
57
- return null;
58
- }
59
- export function clearRetryQueue() {
60
- try {
61
- if (existsSync(RETRY_FILE()))
62
- writeFileSync(RETRY_FILE(), JSON.stringify({ questions: [] }, null, 2));
63
- }
64
- catch { }
65
- }
1
+ import{existsSync as t,readFileSync as e,writeFileSync as r}from"node:fs";import{join as n}from"node:path";import{getIcoaDir as s}from"./config.js";const o=()=>n(s(),"demo-stats.json"),a=()=>n(s(),"demo-retry.json"),u={best:0,bestPercentage:0,attempts:0,lastFive:[]};export function getDemoStats(){try{if(!t(o()))return{...u};const r=JSON.parse(e(o(),"utf-8"));return{...u,...r}}catch{return{...u}}}export function recordDemoAttempt(t,e){const n=getDemoStats(),s=e>0?Math.round(t/e*100):0,a={best:Math.max(n.best,t),bestPercentage:Math.max(n.bestPercentage,s),attempts:n.attempts+1,lastFive:[{score:t,total:e,at:(new Date).toISOString()},...n.lastFive].slice(0,5)};try{r(o(),JSON.stringify(a,null,2))}catch{}return a}export function saveRetryQueue(t){try{r(a(),JSON.stringify({questions:t,savedAt:(new Date).toISOString()},null,2))}catch{}}export function getRetryQueue(){try{if(!t(a()))return null;const r=JSON.parse(e(a(),"utf-8"));if(Array.isArray(r.questions)&&r.questions.length>0)return r.questions}catch{}return null}export function clearRetryQueue(){try{t(a())&&r(a(),JSON.stringify({questions:[]},null,2))}catch{}}
@@ -1,57 +1 @@
1
- export class ExamClient {
2
- baseUrl;
3
- token;
4
- constructor(baseUrl, token) {
5
- this.baseUrl = baseUrl.replace(/\/+$/, '');
6
- this.token = token;
7
- }
8
- async request(method, path, body) {
9
- // Try nginx proxy first, fallback to direct port
10
- const urls = [
11
- `${this.baseUrl}/api/icoa/exams${path}`,
12
- `${this.baseUrl}:9090/api/icoa/exams${path}`,
13
- ];
14
- let lastError = null;
15
- for (const url of urls) {
16
- try {
17
- return await this._fetch(method, url, body);
18
- }
19
- catch (e) {
20
- lastError = e;
21
- }
22
- }
23
- throw lastError || new Error('Exam API unreachable');
24
- }
25
- async _fetch(method, url, body) {
26
- const res = await fetch(url, {
27
- method,
28
- headers: {
29
- Authorization: `Token ${this.token}`,
30
- 'Content-Type': 'application/json',
31
- },
32
- body: body ? JSON.stringify(body) : undefined,
33
- signal: AbortSignal.timeout(10000),
34
- });
35
- if (!res.ok) {
36
- const text = await res.text().catch(() => 'Unknown error');
37
- throw new Error(`Exam API error (${res.status}): ${text}`);
38
- }
39
- const json = await res.json();
40
- if (json.success === false) {
41
- throw new Error(json.message || 'Exam API error');
42
- }
43
- return json.data;
44
- }
45
- async getExams() {
46
- return this.request('GET', '');
47
- }
48
- async startExam(examId) {
49
- return this.request('POST', `/${examId}/start`);
50
- }
51
- async submitExam(examId, answers) {
52
- return this.request('POST', `/${examId}/submit`, { answers });
53
- }
54
- async getResult(examId) {
55
- return this.request('GET', `/${examId}/result`);
56
- }
57
- }
1
+ export class ExamClient{baseUrl;token;constructor(t,e){this.baseUrl=t.replace(/\/+$/,""),this.token=e}async request(t,e,r){const s=[`${this.baseUrl}/api/icoa/exams${e}`,`${this.baseUrl}:9090/api/icoa/exams${e}`];let a=null;for(const e of s)try{return await this._fetch(t,e,r)}catch(t){a=t}throw a||new Error("Exam API unreachable")}async _fetch(t,e,r){const s=await fetch(e,{method:t,headers:{Authorization:`Token ${this.token}`,"Content-Type":"application/json"},body:r?JSON.stringify(r):void 0,signal:AbortSignal.timeout(1e4)});if(!s.ok){const t=await s.text().catch(()=>"Unknown error");throw new Error(`Exam API error (${s.status}): ${t}`)}const a=await s.json();if(!1===a.success)throw new Error(a.message||"Exam API error");return a.data}async getExams(){return this.request("GET","")}async startExam(t){return this.request("POST",`/${t}/start`)}async submitExam(t,e){return this.request("POST",`/${t}/submit`,{answers:e})}async getResult(t){return this.request("GET",`/${t}/result`)}}
@@ -1,23 +1 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { getIcoaDir } from './config.js';
4
- const SETUP_FILE = () => join(getIcoaDir(), 'exam-setup.json');
5
- export function getExamSetup() {
6
- try {
7
- if (!existsSync(SETUP_FILE()))
8
- return null;
9
- return JSON.parse(readFileSync(SETUP_FILE(), 'utf-8'));
10
- }
11
- catch {
12
- return null;
13
- }
14
- }
15
- export function saveExamSetup(state) {
16
- try {
17
- writeFileSync(SETUP_FILE(), JSON.stringify(state, null, 2));
18
- }
19
- catch { }
20
- }
21
- export function isExamSetupComplete() {
22
- return getExamSetup() !== null;
23
- }
1
+ import{existsSync as t,readFileSync as e,writeFileSync as n}from"node:fs";import{join as r}from"node:path";import{getIcoaDir as o}from"./config.js";const u=()=>r(o(),"exam-setup.json");export function getExamSetup(){try{return t(u())?JSON.parse(e(u(),"utf-8")):null}catch{return null}}export function saveExamSetup(t){try{n(u(),JSON.stringify(t,null,2))}catch{}}export function isExamSetupComplete(){return null!==getExamSetup()}
@@ -1,112 +1 @@
1
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { getIcoaDir } from './config.js';
4
- // Demo and real exam use separate state files so they don't block each other
5
- function stateFile() {
6
- return join(getIcoaDir(), 'exam-state.json');
7
- }
8
- function demoStateFile() {
9
- return join(getIcoaDir(), 'demo-state.json');
10
- }
11
- // Internal: pick the right file based on examId
12
- function resolveStateFile(examId) {
13
- return examId === 'demo-free' ? demoStateFile() : stateFile();
14
- }
15
- export function getRealExamState() {
16
- const f = stateFile();
17
- if (!existsSync(f))
18
- return null;
19
- try {
20
- const state = JSON.parse(readFileSync(f, 'utf-8'));
21
- // Pre-v2.19.45 versions stored demo state in exam-state.json with
22
- // examId='demo-free'. After v2.19.45 demo moved to demo-state.json.
23
- // Ignore stale demo-tagged content found in the real-exam file —
24
- // it's contamination from an old install. Also auto-clean the file.
25
- if (state?.session?.examId === 'demo-free') {
26
- try {
27
- unlinkSync(f);
28
- }
29
- catch { }
30
- return null;
31
- }
32
- return state;
33
- }
34
- catch {
35
- return null;
36
- }
37
- }
38
- export function getDemoState() {
39
- const f = demoStateFile();
40
- if (!existsSync(f))
41
- return null;
42
- try {
43
- return JSON.parse(readFileSync(f, 'utf-8'));
44
- }
45
- catch {
46
- return null;
47
- }
48
- }
49
- export function getExamState() {
50
- // Real exam ALWAYS takes priority over demo when both exist.
51
- // Reasoning: real exam is token-gated, has a timer, and represents a serious
52
- // commitment. Demo is casual practice. If a real exam is in progress, all
53
- // shared commands (exam q N, exam answer, ai4ctf, ctf4ai) must target the
54
- // real exam — never silently fall back to demo questions, even if demo was
55
- // run more recently. (Demo→Exam→Finals progression principle, exam.md §0)
56
- const real = getRealExamState();
57
- if (real)
58
- return real;
59
- return getDemoState();
60
- }
61
- export function saveExamState(state) {
62
- const f = resolveStateFile(state.session.examId);
63
- writeFileSync(f, JSON.stringify(state, null, 2));
64
- }
65
- export function clearExamState(examId) {
66
- if (examId) {
67
- const f = resolveStateFile(examId);
68
- if (existsSync(f))
69
- unlinkSync(f);
70
- }
71
- else {
72
- // Clear the currently active exam
73
- const state = getExamState();
74
- if (state) {
75
- const f = resolveStateFile(state.session.examId);
76
- if (existsSync(f))
77
- unlinkSync(f);
78
- }
79
- }
80
- }
81
- export function isExamActive() {
82
- return getExamState() !== null;
83
- }
84
- export function getExamDeadline() {
85
- const state = getExamState();
86
- if (!state)
87
- return null;
88
- if (!state.session.durationMinutes)
89
- return null; // 0 = no time limit
90
- // Preferred path: server-time sync succeeded at exam start. deadlineServerMs
91
- // is the authoritative server-time moment of expiry. Convert back to local
92
- // clock domain so existing callers (which compare against Date.now()) stay
93
- // correct even if the local clock drifts from server.
94
- const { deadlineServerMs, clockOffsetMs } = state.session;
95
- if (typeof deadlineServerMs === 'number' && typeof clockOffsetMs === 'number') {
96
- return new Date(deadlineServerMs - clockOffsetMs);
97
- }
98
- // Fallback: local-clock-only (pre-v2.19.85 behavior). Accurate as long as
99
- // client and server clocks agree; off by the clock skew otherwise.
100
- const startTime = state.session.confirmedAt || state.session.startedAt;
101
- const start = new Date(startTime).getTime();
102
- return new Date(start + state.session.durationMinutes * 60 * 1000);
103
- }
104
- export function addInteraction(interaction) {
105
- const state = getExamState();
106
- if (!state)
107
- return;
108
- if (!state.interactions)
109
- state.interactions = [];
110
- state.interactions.push(interaction);
111
- saveExamState(state);
112
- }
1
+ import{readFileSync as t,writeFileSync as e,existsSync as n,unlinkSync as r}from"node:fs";import{join as o}from"node:path";import{getIcoaDir as s}from"./config.js";function a(){return o(s(),"exam-state.json")}function i(){return o(s(),"demo-state.json")}function u(t){return"demo-free"===t?i():a()}export function getRealExamState(){const e=a();if(!n(e))return null;try{const n=JSON.parse(t(e,"utf-8"));if("demo-free"===n?.session?.examId){try{r(e)}catch{}return null}return n}catch{return null}}export function getDemoState(){const e=i();if(!n(e))return null;try{return JSON.parse(t(e,"utf-8"))}catch{return null}}export function getExamState(){return getRealExamState()||getDemoState()}export function saveExamState(t){const n=u(t.session.examId);e(n,JSON.stringify(t,null,2))}export function clearExamState(t){if(t){const e=u(t);n(e)&&r(e)}else{const t=getExamState();if(t){const e=u(t.session.examId);n(e)&&r(e)}}}export function isExamActive(){return null!==getExamState()}export function getExamDeadline(){const t=getExamState();if(!t)return null;if(!t.session.durationMinutes)return null;const{deadlineServerMs:e,clockOffsetMs:n}=t.session;if("number"==typeof e&&"number"==typeof n)return new Date(e-n);const r=t.session.confirmedAt||t.session.startedAt,o=new Date(r).getTime();return new Date(o+60*t.session.durationMinutes*1e3)}export function addInteraction(t){const e=getExamState();e&&(e.interactions||(e.interactions=[]),e.interactions.push(t),saveExamState(e))}
@@ -1,235 +1 @@
1
- import { GoogleGenAI } from '@google/genai';
2
- import chalk from 'chalk';
3
- import { readFileSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { getConfig, saveConfig } from './config.js';
7
- import { getRealExamState } from './exam-state.js';
8
- const __dirname_gemini = dirname(fileURLToPath(import.meta.url));
9
- let _cachedVersion = null;
10
- function getCliVersion() {
11
- if (_cachedVersion)
12
- return _cachedVersion;
13
- try {
14
- const pkg = JSON.parse(readFileSync(join(__dirname_gemini, '..', '..', 'package.json'), 'utf-8'));
15
- _cachedVersion = pkg.version || 'unknown';
16
- }
17
- catch {
18
- _cachedVersion = 'unknown';
19
- }
20
- return _cachedVersion;
21
- }
22
- const SYSTEM_PROMPTS = {
23
- A: `You are an AI assistant in a cybersecurity CTF competition called ICOA.
24
- You are providing Level A (General Guidance) to a competitor.
25
-
26
- STRICT RULES:
27
- - Only answer conceptual questions
28
- - Do NOT mention specific vulnerability names
29
- - Do NOT provide any code, commands, or tool usage
30
- - Do NOT mention specific attack techniques
31
- - Use questions to guide the competitor toward their own discovery
32
- - If the competitor asks you to solve the challenge, refuse and redirect
33
- - Never output anything matching flag format: icoa{...}`,
34
- B: `You are an AI assistant in ICOA CTF, providing Level B (Deep Analysis).
35
-
36
- RULES:
37
- - You MAY identify specific vulnerability types (e.g., "buffer overflow")
38
- - You MAY suggest which category of tool to use (e.g., "a debugger")
39
- - Do NOT provide complete commands or working code
40
- - Do NOT provide exploit code or payloads
41
- - Do NOT provide flags or flag fragments
42
- - Never output anything matching: icoa{...}`,
43
- C: `You are an AI assistant in ICOA CTF, providing Level C (Critical Assist).
44
-
45
- RULES:
46
- - You MAY provide the key conceptual breakthrough
47
- - You MAY name specific algorithms or approaches
48
- - Do NOT provide complete exploit code
49
- - Do NOT provide the flag
50
- - Never output anything matching: icoa{...}`,
51
- };
52
- function buildSystemPrompt(level, context) {
53
- let prompt = SYSTEM_PROMPTS[level];
54
- if (context) {
55
- prompt += `\n\nThe competitor is currently working on:\nChallenge: ${context.name}\nCategory: ${context.category}`;
56
- }
57
- return prompt;
58
- }
59
- export function filterFlagPatterns(text) {
60
- return text.replace(/icoa\{[^}]*\}/gi, '[FLAG REDACTED]');
61
- }
62
- // No DEFAULT_API_KEY in client code — keys stay server-side only.
63
- // For demo mode (no user key), createChatSession routes through the
64
- // server proxy at /api/icoa/ai/chat. For users with their own key
65
- // (via setup or env var), we go direct to the Gemini SDK.
66
- function getApiKey() {
67
- return process.env.GEMINI_API_KEY || getConfig().geminiApiKey || '';
68
- }
69
- function getClient(apiKey) {
70
- return new GoogleGenAI({ apiKey });
71
- }
72
- export async function generateHint(level, question, context) {
73
- let apiKey = getApiKey();
74
- if (!apiKey) {
75
- try {
76
- const { input } = await import('@inquirer/prompts');
77
- console.log();
78
- console.log(chalk.yellow(' Gemini API key not configured.'));
79
- console.log(chalk.gray(' Get one free at: ') + chalk.cyan('https://aistudio.google.com/apikey'));
80
- console.log();
81
- apiKey = await input({ message: 'Enter your Gemini API Key:' });
82
- if (apiKey.trim()) {
83
- apiKey = apiKey.trim();
84
- saveConfig({ geminiApiKey: apiKey });
85
- console.log(chalk.green(' Key saved for future use.'));
86
- console.log();
87
- }
88
- else {
89
- throw new Error('No API key provided.');
90
- }
91
- }
92
- catch {
93
- throw new Error('Gemini API key not configured.\n' +
94
- 'Set GEMINI_API_KEY environment variable, or run: icoa setup');
95
- }
96
- }
97
- const config = getConfig();
98
- const modelName = config.geminiModel || 'gemma-4-31b-it';
99
- const ai = getClient(apiKey);
100
- const response = await ai.models.generateContent({
101
- model: modelName,
102
- config: { systemInstruction: buildSystemPrompt(level, context) },
103
- contents: question,
104
- });
105
- const text = filterFlagPatterns(response.text ?? '');
106
- const usage = response.usageMetadata;
107
- const tokensUsed = (usage?.promptTokenCount || 0) + (usage?.candidatesTokenCount || 0);
108
- return { text, tokensUsed };
109
- }
110
- // Use the strongest model for translation quality
111
- const TRANSLATION_MODEL = 'gemini-3.1-pro-preview';
112
- export async function translateText(text, targetLang) {
113
- const apiKey = getApiKey();
114
- if (!apiKey) {
115
- throw new Error('Gemini API key not configured for translation.');
116
- }
117
- const ai = getClient(apiKey);
118
- const prompt = `Translate the following CTF challenge description to ${targetLang}.
119
- Keep all technical terms, code, commands, URLs, and flag formats in English.
120
- Only translate the narrative/descriptive text.
121
-
122
- ${text}`;
123
- try {
124
- const response = await ai.models.generateContent({
125
- model: TRANSLATION_MODEL,
126
- contents: prompt,
127
- });
128
- return response.text ?? '';
129
- }
130
- catch {
131
- // Fallback to flash if pro not available
132
- const config = getConfig();
133
- const fallback = config.geminiModel || 'gemma-4-31b-it';
134
- const response = await ai.models.generateContent({
135
- model: fallback,
136
- contents: prompt,
137
- });
138
- return response.text ?? '';
139
- }
140
- }
141
- export function setApiKey(key) {
142
- saveConfig({ geminiApiKey: key });
143
- }
144
- const CHAT_SYSTEM_PROMPT = `You are an AI teammate in the ICOA cybersecurity CTF competition (International Cyber Olympiad in AI 2026, Sydney).
145
-
146
- You're a friendly, knowledgeable cybersecurity partner — like a fellow competitor sitting next to the user. Be conversational, encouraging, and collaborative.
147
-
148
- RULES:
149
- - Help the competitor think through challenges, brainstorm approaches, explain concepts
150
- - You MAY discuss vulnerability types, tools, techniques, and methodologies
151
- - You MAY suggest approaches and help debug code
152
- - Do NOT provide complete working exploits or full solution scripts
153
- - Do NOT provide flags or flag fragments
154
- - Never output anything matching flag format: icoa{...}
155
- - If you don't know something, say so honestly
156
- - Keep responses concise unless the user asks for detail
157
- - When the user opens a challenge, use the context to give relevant advice`;
158
- export async function createChatSession(context, customSystemPrompt) {
159
- const config = getConfig();
160
- const apiKey = getApiKey();
161
- let systemPrompt = customSystemPrompt || CHAT_SYSTEM_PROMPT;
162
- if (context) {
163
- systemPrompt += `\n\nThe competitor is currently working on:\nChallenge: ${context.name}\nCategory: ${context.category}`;
164
- }
165
- // ─── Path A: user has their own key → direct Gemini SDK ───
166
- if (apiKey) {
167
- const modelName = config.geminiModel || 'gemini-2.5-flash';
168
- const ai = getClient(apiKey);
169
- const chat = ai.chats.create({
170
- model: modelName,
171
- config: { systemInstruction: systemPrompt },
172
- });
173
- return {
174
- async sendMessage(msg) {
175
- const response = await chat.sendMessage({ message: msg });
176
- const text = filterFlagPatterns(response.text ?? '');
177
- const usage = response.usageMetadata;
178
- const tokensUsed = usage?.totalTokenCount || ((usage?.promptTokenCount || 0) + (usage?.candidatesTokenCount || 0));
179
- return { text, tokensUsed };
180
- },
181
- };
182
- }
183
- // ─── Path B: no key → server proxy (key stays server-side) ───
184
- const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
185
- const fp = config.deviceFingerprint || '';
186
- const modelName = config.geminiModel || 'gemini-2.5-flash';
187
- const messages = [];
188
- return {
189
- async sendMessage(msg) {
190
- messages.push({ role: 'user', text: msg });
191
- // Attach exam token if contestant is in a real exam — grants full
192
- // 2048 maxTokens and higher rate limit. Demo/anonymous users rely on
193
- // the User-Agent gate on the server side.
194
- const realExam = getRealExamState();
195
- const payload = {
196
- systemPrompt,
197
- messages,
198
- model: modelName,
199
- maxTokens: 2048,
200
- deviceFingerprint: fp,
201
- };
202
- if (realExam?.session?.token) {
203
- payload.examToken = realExam.session.token;
204
- }
205
- const res = await fetch(`${serverUrl}/api/icoa/ai/chat`, {
206
- method: 'POST',
207
- headers: {
208
- 'Content-Type': 'application/json',
209
- 'User-Agent': `icoa-cli/${getCliVersion()}`,
210
- },
211
- body: JSON.stringify(payload),
212
- signal: AbortSignal.timeout(60_000),
213
- });
214
- if (!res.ok) {
215
- const err = await res.json().catch(() => ({ message: 'AI proxy error' }));
216
- const msg = err.message || `AI proxy returned ${res.status}`;
217
- if (res.status === 401) {
218
- throw new Error(chalk.yellow('⚠ ') + 'Exam token expired. Re-enter via `exam <token>`.');
219
- }
220
- if (res.status === 403) {
221
- throw new Error(chalk.yellow('⚠ ') + msg);
222
- }
223
- if (res.status === 429) {
224
- throw new Error(chalk.yellow('⏳ ') + msg);
225
- }
226
- throw new Error(msg);
227
- }
228
- const json = await res.json();
229
- const text = filterFlagPatterns(json.data?.text || '');
230
- const tokensUsed = json.data?.tokensUsed || 0;
231
- messages.push({ role: 'model', text });
232
- return { text, tokensUsed };
233
- },
234
- };
235
- }
1
+ import{GoogleGenAI as e}from"@google/genai";import chalk from"chalk";import{readFileSync as t}from"node:fs";import{dirname as n,join as o}from"node:path";import{fileURLToPath as r}from"node:url";import{getConfig as i,saveConfig as a}from"./config.js";import{getRealExamState as s}from"./exam-state.js";const c=n(r(import.meta.url));let l=null;function g(){if(l)return l;try{const e=JSON.parse(t(o(c,"..","..","package.json"),"utf-8"));l=e.version||"unknown"}catch{l="unknown"}return l}const u={A:"You are an AI assistant in a cybersecurity CTF competition called ICOA.\nYou are providing Level A (General Guidance) to a competitor.\n\nSTRICT RULES:\n- Only answer conceptual questions\n- Do NOT mention specific vulnerability names\n- Do NOT provide any code, commands, or tool usage\n- Do NOT mention specific attack techniques\n- Use questions to guide the competitor toward their own discovery\n- If the competitor asks you to solve the challenge, refuse and redirect\n- Never output anything matching flag format: icoa{...}",B:'You are an AI assistant in ICOA CTF, providing Level B (Deep Analysis).\n\nRULES:\n- You MAY identify specific vulnerability types (e.g., "buffer overflow")\n- You MAY suggest which category of tool to use (e.g., "a debugger")\n- Do NOT provide complete commands or working code\n- Do NOT provide exploit code or payloads\n- Do NOT provide flags or flag fragments\n- Never output anything matching: icoa{...}',C:"You are an AI assistant in ICOA CTF, providing Level C (Critical Assist).\n\nRULES:\n- You MAY provide the key conceptual breakthrough\n- You MAY name specific algorithms or approaches\n- Do NOT provide complete exploit code\n- Do NOT provide the flag\n- Never output anything matching: icoa{...}"};function p(e,t){let n=u[e];return t&&(n+=`\n\nThe competitor is currently working on:\nChallenge: ${t.name}\nCategory: ${t.category}`),n}export function filterFlagPatterns(e){return e.replace(/icoa\{[^}]*\}/gi,"[FLAG REDACTED]")}function m(){return process.env.GEMINI_API_KEY||i().geminiApiKey||""}function d(t){return new e({apiKey:t})}export async function generateHint(e,t,n){let o=m();if(!o)try{const{input:e}=await import("@inquirer/prompts");if(console.log(),console.log(chalk.yellow(" Gemini API key not configured.")),console.log(chalk.gray(" Get one free at: ")+chalk.cyan("https://aistudio.google.com/apikey")),console.log(),o=await e({message:"Enter your Gemini API Key:"}),!o.trim())throw new Error("No API key provided.");o=o.trim(),a({geminiApiKey:o}),console.log(chalk.green(" Key saved for future use.")),console.log()}catch{throw new Error("Gemini API key not configured.\nSet GEMINI_API_KEY environment variable, or run: icoa setup")}const r=i().geminiModel||"gemma-4-31b-it",s=d(o),c=await s.models.generateContent({model:r,config:{systemInstruction:p(e,n)},contents:t}),l=filterFlagPatterns(c.text??""),g=c.usageMetadata;return{text:l,tokensUsed:(g?.promptTokenCount||0)+(g?.candidatesTokenCount||0)}}export async function translateText(e,t){const n=m();if(!n)throw new Error("Gemini API key not configured for translation.");const o=d(n),r=`Translate the following CTF challenge description to ${t}.\nKeep all technical terms, code, commands, URLs, and flag formats in English.\nOnly translate the narrative/descriptive text.\n\n${e}`;try{return(await o.models.generateContent({model:"gemini-3.1-pro-preview",contents:r})).text??""}catch{const e=i().geminiModel||"gemma-4-31b-it";return(await o.models.generateContent({model:e,contents:r})).text??""}}export function setApiKey(e){a({geminiApiKey:e})}export async function createChatSession(e,t){const n=i(),o=m();let r=t||"You are an AI teammate in the ICOA cybersecurity CTF competition (International Cyber Olympiad in AI 2026, Sydney).\n\nYou're a friendly, knowledgeable cybersecurity partner — like a fellow competitor sitting next to the user. Be conversational, encouraging, and collaborative.\n\nRULES:\n- Help the competitor think through challenges, brainstorm approaches, explain concepts\n- You MAY discuss vulnerability types, tools, techniques, and methodologies\n- You MAY suggest approaches and help debug code\n- Do NOT provide complete working exploits or full solution scripts\n- Do NOT provide flags or flag fragments\n- Never output anything matching flag format: icoa{...}\n- If you don't know something, say so honestly\n- Keep responses concise unless the user asks for detail\n- When the user opens a challenge, use the context to give relevant advice";if(e&&(r+=`\n\nThe competitor is currently working on:\nChallenge: ${e.name}\nCategory: ${e.category}`),o){const e=n.geminiModel||"gemini-2.5-flash",t=d(o).chats.create({model:e,config:{systemInstruction:r}});return{async sendMessage(e){const n=await t.sendMessage({message:e}),o=filterFlagPatterns(n.text??""),r=n.usageMetadata;return{text:o,tokensUsed:r?.totalTokenCount||(r?.promptTokenCount||0)+(r?.candidatesTokenCount||0)}}}}const a=n.ctfdUrl||"https://practice.icoa2026.au",c=n.deviceFingerprint||"",l=n.geminiModel||"gemini-2.5-flash",u=[];return{async sendMessage(e){u.push({role:"user",text:e});const t=s(),n={systemPrompt:r,messages:u,model:l,maxTokens:2048,deviceFingerprint:c};t?.session?.token&&(n.examToken=t.session.token);const o=await fetch(`${a}/api/icoa/ai/chat`,{method:"POST",headers:{"Content-Type":"application/json","User-Agent":`icoa-cli/${g()}`},body:JSON.stringify(n),signal:AbortSignal.timeout(6e4)});if(!o.ok){const e=(await o.json().catch(()=>({message:"AI proxy error"}))).message||`AI proxy returned ${o.status}`;if(401===o.status)throw new Error(chalk.yellow("⚠ ")+"Exam token expired. Re-enter via `exam <token>`.");if(403===o.status)throw new Error(chalk.yellow("⚠ ")+e);if(429===o.status)throw new Error(chalk.yellow("⏳ ")+e);throw new Error(e)}const i=await o.json(),p=filterFlagPatterns(i.data?.text||""),m=i.data?.tokensUsed||0;return u.push({role:"model",text:p}),{text:p,tokensUsed:m}}}}