icoa-cli 2.19.100 → 2.19.102
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.
- package/dist/commands/ai4ctf.js +1 -700
- package/dist/commands/connect.js +1 -66
- package/dist/commands/ctf.js +1 -620
- package/dist/commands/ctf4ai-demo.js +1 -525
- package/dist/commands/env.js +1 -738
- package/dist/commands/exam.js +1 -2353
- package/dist/commands/files.js +1 -52
- package/dist/commands/hint.js +1 -119
- package/dist/commands/lang.js +1 -155
- package/dist/commands/log.js +1 -165
- package/dist/commands/note.js +1 -40
- package/dist/commands/ref.js +1 -68
- package/dist/commands/setup.js +1 -122
- package/dist/commands/shell.js +1 -55
- package/dist/commands/theme.js +1 -50
- package/dist/index.js +1 -225
- package/dist/lib/access.js +1 -246
- package/dist/lib/budget.js +1 -42
- package/dist/lib/colors.js +1 -21
- package/dist/lib/config.js +1 -60
- package/dist/lib/ctfd-client.js +1 -274
- package/dist/lib/demo-exam.js +1 -249
- package/dist/lib/demo-flags.js +1 -27
- package/dist/lib/demo-stats.js +1 -65
- package/dist/lib/exam-client.js +1 -57
- package/dist/lib/exam-setup.js +1 -23
- package/dist/lib/exam-state.js +1 -112
- package/dist/lib/gemini.js +1 -235
- package/dist/lib/i18n.js +1 -273
- package/dist/lib/log-sync.js +1 -110
- package/dist/lib/logger.js +1 -59
- package/dist/lib/paper-upgrade.js +1 -117
- package/dist/lib/platform.js +1 -86
- package/dist/lib/sandbox.js +1 -93
- package/dist/lib/terminal.js +1 -49
- package/dist/lib/theme.js +1 -108
- package/dist/lib/translation.js +1 -66
- package/dist/lib/ui.js +1 -80
- package/dist/lib/update-check.js +1 -102
- package/dist/postinstall.js +1 -48
- package/dist/repl.js +1 -1281
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.js +1 -38
- package/package.json +6 -2
- package/translations/sw/i18n-snippet.ts +1 -0
package/dist/lib/demo-stats.js
CHANGED
|
@@ -1,65 +1 @@
|
|
|
1
|
-
import
|
|
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{}}
|
package/dist/lib/exam-client.js
CHANGED
|
@@ -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`)}}
|
package/dist/lib/exam-setup.js
CHANGED
|
@@ -1,23 +1 @@
|
|
|
1
|
-
import
|
|
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()}
|
package/dist/lib/exam-state.js
CHANGED
|
@@ -1,112 +1 @@
|
|
|
1
|
-
import
|
|
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))}
|
package/dist/lib/gemini.js
CHANGED
|
@@ -1,235 +1 @@
|
|
|
1
|
-
import {
|
|
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}}}}
|