icoa-cli 2.19.355 → 2.19.357
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 -1
- package/dist/commands/arena.js +1 -1
- package/dist/commands/ctf.js +787 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/demo2.js +1502 -1
- package/dist/commands/exam.js +1 -1
- package/dist/commands/files.js +59 -1
- package/dist/commands/ipynb.d.ts +10 -4
- package/dist/commands/ipynb.js +1 -1
- package/dist/commands/lang.js +202 -1
- package/dist/commands/log.js +171 -1
- package/dist/commands/shell.d.ts +15 -0
- package/dist/commands/shell.js +151 -1
- package/dist/commands/sim.js +389 -1
- package/dist/index.js +355 -1
- package/dist/lib/access.js +184 -1
- package/dist/lib/aienv.js +205 -1
- package/dist/lib/arena-submit.js +21 -1
- package/dist/lib/banner.js +31 -1
- package/dist/lib/budget.js +6 -1
- package/dist/lib/challenge-dir.js +16 -1
- package/dist/lib/colors.js +17 -1
- package/dist/lib/comms.js +212 -1
- package/dist/lib/config.js +93 -1
- package/dist/lib/countdown.js +43 -1
- package/dist/lib/country-lang.js +39 -1
- package/dist/lib/ctfd-client.js +417 -1
- package/dist/lib/demo-exam.js +478 -1
- package/dist/lib/demo-flags.js +27 -1
- package/dist/lib/demo-stats.js +62 -1
- package/dist/lib/demo2-progress.js +102 -1
- package/dist/lib/docker-probe.d.ts +45 -0
- package/dist/lib/docker-probe.js +118 -0
- package/dist/lib/editor-spawn.d.ts +23 -0
- package/dist/lib/editor-spawn.js +53 -0
- package/dist/lib/exam-client.js +54 -1
- package/dist/lib/exam-sandbox.js +201 -1
- package/dist/lib/exam-setup.js +36 -1
- package/dist/lib/exam-state.js +273 -1
- package/dist/lib/gemini.js +247 -1
- package/dist/lib/i18n.js +302 -1
- package/dist/lib/integrity-snapshot.js +88 -1
- package/dist/lib/interactive-spawn.js +55 -1
- package/dist/lib/ipynb-input.js +65 -1
- package/dist/lib/kernel-protocol.js +88 -1
- package/dist/lib/kernel.js +146 -2
- package/dist/lib/learn-curricula.js +309 -1
- package/dist/lib/learn-i18n.js +184 -1
- package/dist/lib/learn-input.js +101 -1
- package/dist/lib/learn-render.js +863 -1
- package/dist/lib/learn-state.js +103 -1
- package/dist/lib/log-sync.js +155 -1
- package/dist/lib/logger.js +49 -1
- package/dist/lib/main-rl.js +7 -1
- package/dist/lib/menu-nav.js +105 -1
- package/dist/lib/notebook-doc.d.ts +38 -0
- package/dist/lib/notebook-doc.js +137 -0
- package/dist/lib/open-file.js +55 -1
- package/dist/lib/paper-upgrade.js +119 -1
- package/dist/lib/platform.js +99 -1
- package/dist/lib/render-card.js +112 -1
- package/dist/lib/repl-asker.js +67 -1
- package/dist/lib/sample-runner.js +227 -1
- package/dist/lib/sandbox.d.ts +25 -1
- package/dist/lib/sandbox.js +144 -1
- package/dist/lib/shell-split.js +69 -1
- package/dist/lib/sim-cooldown.js +75 -1
- package/dist/lib/theme.js +119 -1
- package/dist/lib/token-format.js +74 -1
- package/dist/lib/tool-man.js +418 -1
- package/dist/lib/toolset-hash.js +48 -1
- package/dist/lib/translation.js +80 -1
- package/dist/lib/translations-fetcher.js +95 -1
- package/dist/lib/ui.js +99 -1
- package/dist/lib/update-check.js +114 -1
- package/dist/lib/version.js +24 -1
- package/dist/postinstall.js +48 -1
- package/dist/repl.js +2391 -1
- package/dist/types/index.js +63 -1
- package/package.json +1 -1
package/dist/lib/exam-state.js
CHANGED
|
@@ -1 +1,273 @@
|
|
|
1
|
-
import{readFileSync
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { getIcoaDir } from './config.js';
|
|
6
|
+
/**
|
|
7
|
+
* Write a practical question's attachedData blob to the contestant's
|
|
8
|
+
* workspace so they can use shell / python / editor tooling on it without
|
|
9
|
+
* pasting the whole base64 back in on the command line.
|
|
10
|
+
*
|
|
11
|
+
* Because `!shell` / `!python3` inside the REPL runs in the sandbox
|
|
12
|
+
* container (which only mounts its own challenges volume, not the host's
|
|
13
|
+
* workspace), we also mirror the file into the sandbox at
|
|
14
|
+
* /home/competitor/challenges/q<n>.txt via `docker cp` when the sandbox
|
|
15
|
+
* is running. Returns the path students should reference —
|
|
16
|
+
* sandbox-visible when the sandbox is up, else host — so the
|
|
17
|
+
* "Ways to solve" block points at a file that actually exists wherever
|
|
18
|
+
* `!command` executes.
|
|
19
|
+
*
|
|
20
|
+
* Silent on failure: null means no workspace file was materialized, and
|
|
21
|
+
* callers skip the solve-paths guidance.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Map a practical question's TYPE to the AI chat command a contestant should
|
|
25
|
+
* enter for it. The practical section is interleaved (standard paper:
|
|
26
|
+
* Q38 ctf4ai, Q39 ai4ctf, Q40 ctf4ai; Paper E: Q37-46 ai4ctf, Q47-48 ctf4ai),
|
|
27
|
+
* so routing by question NUMBER misroutes questions to the wrong engine — the
|
|
28
|
+
* root cause behind both the old printSectionIntro bug and the practical
|
|
29
|
+
* answer-nudge in repl.ts. Always route by type.
|
|
30
|
+
*
|
|
31
|
+
* Returns null for MCQ / short-answer / unknown types, and (when not a real
|
|
32
|
+
* exam, i.e. the demo) since the demo has no live AI chat sections.
|
|
33
|
+
*/
|
|
34
|
+
export function practicalChatCommand(qType, isReal) {
|
|
35
|
+
if (!isReal)
|
|
36
|
+
return null;
|
|
37
|
+
if (qType === 'ctf4ai')
|
|
38
|
+
return 'ctf4ai';
|
|
39
|
+
if (qType === 'ai4ctf')
|
|
40
|
+
return 'ai4ctf';
|
|
41
|
+
if (qType === 'vla')
|
|
42
|
+
return 'ctf4eai';
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export function materializeAttachedData(qNum, data) {
|
|
46
|
+
if (!data)
|
|
47
|
+
return null;
|
|
48
|
+
let hostPath;
|
|
49
|
+
try {
|
|
50
|
+
// Prefer the active exam's per-exam tmpdir (L1 anti-cheat workspace) so
|
|
51
|
+
// contestants see their question file in the same dir their `!cmd`
|
|
52
|
+
// invocations land in. Falls back to ~/icoa-workspace pre-start.
|
|
53
|
+
const state = getRealExamState();
|
|
54
|
+
const examWorkspace = state?.session?.workspaceDir;
|
|
55
|
+
const workspace = examWorkspace && existsSync(examWorkspace) ? examWorkspace : join(homedir(), 'icoa-workspace');
|
|
56
|
+
mkdirSync(workspace, { recursive: true });
|
|
57
|
+
hostPath = join(workspace, `q${qNum}.txt`);
|
|
58
|
+
writeFileSync(hostPath, data, 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// If the sandbox container is up, copy the file inside so `!command`
|
|
64
|
+
// invocations can reach it at a stable path. Uses execFileSync (no
|
|
65
|
+
// shell) because qNum is numeric and hostPath is trusted, but we still
|
|
66
|
+
// avoid string concatenation into a shell. A no-op when the container
|
|
67
|
+
// isn't running — we just swallow any error and fall back to host.
|
|
68
|
+
try {
|
|
69
|
+
execFileSync('docker', ['cp', hostPath, `icoa-sandbox:/home/competitor/challenges/q${qNum}.txt`], {
|
|
70
|
+
stdio: 'ignore',
|
|
71
|
+
timeout: 3000,
|
|
72
|
+
});
|
|
73
|
+
return `challenges/q${qNum}.txt`;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return hostPath;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Demo and real exam use separate state files so they don't block each other
|
|
80
|
+
function stateFile() {
|
|
81
|
+
return join(getIcoaDir(), 'exam-state.json');
|
|
82
|
+
}
|
|
83
|
+
function demoStateFile() {
|
|
84
|
+
return join(getIcoaDir(), 'demo-state.json');
|
|
85
|
+
}
|
|
86
|
+
// Internal: pick the right file based on examId
|
|
87
|
+
function resolveStateFile(examId) {
|
|
88
|
+
return examId === 'demo-free' ? demoStateFile() : stateFile();
|
|
89
|
+
}
|
|
90
|
+
export function getRealExamState() {
|
|
91
|
+
const f = stateFile();
|
|
92
|
+
if (!existsSync(f))
|
|
93
|
+
return null;
|
|
94
|
+
try {
|
|
95
|
+
const state = JSON.parse(readFileSync(f, 'utf-8'));
|
|
96
|
+
// Pre-v2.19.45 versions stored demo state in exam-state.json with
|
|
97
|
+
// examId='demo-free'. After v2.19.45 demo moved to demo-state.json.
|
|
98
|
+
// Ignore stale demo-tagged content found in the real-exam file —
|
|
99
|
+
// it's contamination from an old install. Also auto-clean the file.
|
|
100
|
+
if (state?.session?.examId === 'demo-free') {
|
|
101
|
+
try {
|
|
102
|
+
unlinkSync(f);
|
|
103
|
+
}
|
|
104
|
+
catch { }
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return state;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function getDemoState() {
|
|
114
|
+
const f = demoStateFile();
|
|
115
|
+
if (!existsSync(f))
|
|
116
|
+
return null;
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(readFileSync(f, 'utf-8'));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function getExamState() {
|
|
125
|
+
// Real exam ALWAYS takes priority over demo when both exist.
|
|
126
|
+
// Reasoning: real exam is token-gated, has a timer, and represents a serious
|
|
127
|
+
// commitment. Demo is casual practice. If a real exam is in progress, all
|
|
128
|
+
// shared commands (exam q N, exam answer, ai4ctf, ctf4ai) must target the
|
|
129
|
+
// real exam — never silently fall back to demo questions, even if demo was
|
|
130
|
+
// run more recently. (Demo→Exam→Finals progression principle, exam.md §0)
|
|
131
|
+
const real = getRealExamState();
|
|
132
|
+
if (real)
|
|
133
|
+
return real;
|
|
134
|
+
return getDemoState();
|
|
135
|
+
}
|
|
136
|
+
export function saveExamState(state) {
|
|
137
|
+
const f = resolveStateFile(state.session.examId);
|
|
138
|
+
writeFileSync(f, JSON.stringify(state, null, 2));
|
|
139
|
+
// V16 (v2.19.181): exam-state.json holds session.token in plaintext.
|
|
140
|
+
// 0o600 so other users on shared hosts can't read it.
|
|
141
|
+
try {
|
|
142
|
+
chmodSync(f, 0o600);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* ignore Windows / restricted FS */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function clearExamState(examId) {
|
|
149
|
+
if (examId) {
|
|
150
|
+
const f = resolveStateFile(examId);
|
|
151
|
+
if (existsSync(f))
|
|
152
|
+
unlinkSync(f);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// Clear the currently active exam
|
|
156
|
+
const state = getExamState();
|
|
157
|
+
if (state) {
|
|
158
|
+
const f = resolveStateFile(state.session.examId);
|
|
159
|
+
if (existsSync(f))
|
|
160
|
+
unlinkSync(f);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Pure: compute the deadline for a given exam state, or null if the
|
|
165
|
+
* session has no time limit. Shared by getExamDeadline (priority-aware)
|
|
166
|
+
* and getRealExamDeadline (real-exam file only). */
|
|
167
|
+
function computeDeadline(state) {
|
|
168
|
+
if (!state.session.durationMinutes)
|
|
169
|
+
return null; // 0 = no time limit
|
|
170
|
+
// Preferred path: server-time sync succeeded at exam start. deadlineServerMs
|
|
171
|
+
// is the authoritative server-time moment of expiry. Convert back to local
|
|
172
|
+
// clock domain so existing callers (which compare against Date.now()) stay
|
|
173
|
+
// correct even if the local clock drifts from server.
|
|
174
|
+
const { deadlineServerMs, clockOffsetMs } = state.session;
|
|
175
|
+
if (typeof deadlineServerMs === 'number' && typeof clockOffsetMs === 'number') {
|
|
176
|
+
return new Date(deadlineServerMs - clockOffsetMs);
|
|
177
|
+
}
|
|
178
|
+
// Fallback: local-clock-only (pre-v2.19.85 behavior). Accurate as long as
|
|
179
|
+
// client and server clocks agree; off by the clock skew otherwise.
|
|
180
|
+
const startTime = state.session.confirmedAt || state.session.startedAt;
|
|
181
|
+
const start = new Date(startTime).getTime();
|
|
182
|
+
return new Date(start + state.session.durationMinutes * 60 * 1000);
|
|
183
|
+
}
|
|
184
|
+
export function getExamDeadline() {
|
|
185
|
+
const state = getExamState();
|
|
186
|
+
return state ? computeDeadline(state) : null;
|
|
187
|
+
}
|
|
188
|
+
/** Deadline read from the real-exam state file specifically, bypassing
|
|
189
|
+
* getExamState()'s real-over-demo priority. Used by `exam demo` to
|
|
190
|
+
* detect a parked expired real exam that would otherwise block demo. */
|
|
191
|
+
export function getRealExamDeadline() {
|
|
192
|
+
const state = getRealExamState();
|
|
193
|
+
return state ? computeDeadline(state) : null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Back-fill missing attachedData / description on practical questions by
|
|
197
|
+
* re-fetching from the server. Older state files (saved before v2.19.104)
|
|
198
|
+
* may have questions whose attachedData was dropped or never stored, leaving
|
|
199
|
+
* the contestant stuck on Q31-37 ("where is the attached file?"). This runs
|
|
200
|
+
* at most once per REPL session and is a no-op if:
|
|
201
|
+
* - no session token is stored (can't authenticate the refetch)
|
|
202
|
+
* - all practical questions already have attachedData
|
|
203
|
+
* - the network is unreachable
|
|
204
|
+
* Returns the (possibly updated) state so callers can use it directly.
|
|
205
|
+
*/
|
|
206
|
+
let _resyncAttempted = false;
|
|
207
|
+
export async function refetchQuestionDataIfStale() {
|
|
208
|
+
const state = getRealExamState();
|
|
209
|
+
if (!state)
|
|
210
|
+
return null;
|
|
211
|
+
if (_resyncAttempted)
|
|
212
|
+
return state;
|
|
213
|
+
const token = state.session.token;
|
|
214
|
+
if (!token)
|
|
215
|
+
return state;
|
|
216
|
+
// Only refetch if at least one practical question references attachedData
|
|
217
|
+
// but doesn't have it locally — otherwise we'd burn a server round-trip
|
|
218
|
+
// on every ai4ctf entry.
|
|
219
|
+
const needsRefetch = state.questions.some((q) => {
|
|
220
|
+
const isPractical = q.type === 'ai4ctf' || q.type === 'ctf4ai';
|
|
221
|
+
if (!isPractical)
|
|
222
|
+
return false;
|
|
223
|
+
const desc = String(q.description || '');
|
|
224
|
+
const mentionsAttached = desc.includes('attachedData') || /base64 blob/i.test(desc);
|
|
225
|
+
return mentionsAttached && !q.attachedData;
|
|
226
|
+
});
|
|
227
|
+
if (!needsRefetch)
|
|
228
|
+
return state;
|
|
229
|
+
_resyncAttempted = true;
|
|
230
|
+
try {
|
|
231
|
+
const { getConfig } = await import('./config.js');
|
|
232
|
+
const config = getConfig();
|
|
233
|
+
const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
|
|
234
|
+
const lang = config.language || 'en';
|
|
235
|
+
const res = await fetch(`${serverUrl}/api/icoa/exam-token`, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: { 'Content-Type': 'application/json' },
|
|
238
|
+
body: JSON.stringify({ token, deviceHash: '', lang }),
|
|
239
|
+
signal: AbortSignal.timeout(8000),
|
|
240
|
+
});
|
|
241
|
+
if (!res.ok)
|
|
242
|
+
return state;
|
|
243
|
+
const json = (await res.json());
|
|
244
|
+
const fresh = json?.data?.questions || [];
|
|
245
|
+
if (fresh.length === 0)
|
|
246
|
+
return state;
|
|
247
|
+
// Merge: for every local question, if the fresh copy has attachedData or
|
|
248
|
+
// a longer description, copy those fields in. Keep local answer state /
|
|
249
|
+
// eliminated options etc. untouched — we only patch read-only content.
|
|
250
|
+
const byNum = new Map();
|
|
251
|
+
for (const fq of fresh)
|
|
252
|
+
byNum.set(fq.number, fq);
|
|
253
|
+
let patched = 0;
|
|
254
|
+
for (const q of state.questions) {
|
|
255
|
+
const fq = byNum.get(q.number);
|
|
256
|
+
if (!fq)
|
|
257
|
+
continue;
|
|
258
|
+
if (fq.attachedData && !q.attachedData) {
|
|
259
|
+
q.attachedData = fq.attachedData;
|
|
260
|
+
patched++;
|
|
261
|
+
}
|
|
262
|
+
if (fq.description && (!q.description || fq.description.length > q.description.length)) {
|
|
263
|
+
q.description = fq.description;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (patched > 0)
|
|
267
|
+
saveExamState(state);
|
|
268
|
+
return state;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return state;
|
|
272
|
+
}
|
|
273
|
+
}
|
package/dist/lib/gemini.js
CHANGED
|
@@ -1 +1,247 @@
|
|
|
1
|
-
import
|
|
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
|
+
prompt += METHOD_NOT_ANSWER_RULE;
|
|
58
|
+
return prompt;
|
|
59
|
+
}
|
|
60
|
+
function filterFlagPatterns(text) {
|
|
61
|
+
return text.replace(/icoa\{[^}]*\}/gi, '[FLAG REDACTED]');
|
|
62
|
+
}
|
|
63
|
+
// No DEFAULT_API_KEY in client code — keys stay server-side only.
|
|
64
|
+
// 2026-04-24: **Local model path disabled**. All AI routes through the
|
|
65
|
+
// server proxy at /api/icoa/ai/chat regardless of whether the user has
|
|
66
|
+
// their own GEMINI_API_KEY. See reference_ai_models.md / feedback_icoa_all_keys.md.
|
|
67
|
+
// ICOA centrally owns all AI keys; students do not need to configure anything.
|
|
68
|
+
const LOCAL_MODEL_DISABLED_MESSAGE = 'Local AI model path has been disabled (2026-04-24). ' +
|
|
69
|
+
'ICOA provides all AI server-side — no API key setup needed. ' +
|
|
70
|
+
'Use `ai4ctf` (chat) or `exam` (with integrated help/hint) instead.';
|
|
71
|
+
function getApiKey() {
|
|
72
|
+
// Returns empty string to disable Path A in createChatSession / generateHint.
|
|
73
|
+
// Preserved as a function to keep the call sites stable for future refactors.
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
function getClient(apiKey) {
|
|
77
|
+
return new GoogleGenAI({ apiKey });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* @deprecated Runtime translation is disabled. Translations are pre-baked
|
|
81
|
+
* at build time via `panda/translate-*.js` scripts and shipped in
|
|
82
|
+
* `translations/<lang>/` — CLI never translates at runtime.
|
|
83
|
+
*/
|
|
84
|
+
export async function translateText(_text, _targetLang) {
|
|
85
|
+
throw new Error(LOCAL_MODEL_DISABLED_MESSAGE);
|
|
86
|
+
}
|
|
87
|
+
export function setApiKey(key) {
|
|
88
|
+
saveConfig({ geminiApiKey: key });
|
|
89
|
+
}
|
|
90
|
+
const CHAT_SYSTEM_PROMPT = `You are an AI teammate in the ICOA cybersecurity CTF competition (International Cyber Olympiad in AI 2026, Sydney).
|
|
91
|
+
|
|
92
|
+
You're a friendly, knowledgeable cybersecurity partner — like a fellow competitor sitting next to the user. Be conversational, encouraging, and collaborative.
|
|
93
|
+
|
|
94
|
+
RULES:
|
|
95
|
+
- Help the competitor think through challenges, brainstorm approaches, explain concepts
|
|
96
|
+
- You MAY discuss vulnerability types, tools, techniques, and methodologies
|
|
97
|
+
- You MAY suggest approaches and help debug code
|
|
98
|
+
- Do NOT provide complete working exploits or full solution scripts
|
|
99
|
+
- Do NOT provide flags or flag fragments
|
|
100
|
+
- Never output anything matching flag format: icoa{...}
|
|
101
|
+
- If you don't know something, say so honestly
|
|
102
|
+
- Keep responses concise unless the user asks for detail
|
|
103
|
+
- When the user opens a challenge, use the context to give relevant advice`;
|
|
104
|
+
// Shared "method, not answer" rule appended to every system prompt. The
|
|
105
|
+
// server (token-api.py) enforces the authoritative copy of these rules so a
|
|
106
|
+
// modified/curl client can't strip them; this client copy keeps the stock CLI
|
|
107
|
+
// well-behaved without a server round-trip. Phrased to allow naming the
|
|
108
|
+
// command but forbid performing the step and returning its result.
|
|
109
|
+
const METHOD_NOT_ANSWER_RULE = `
|
|
110
|
+
|
|
111
|
+
INTEGRITY — give method, not the produced answer:
|
|
112
|
+
- You may name the tool/command/technique (e.g. "this is base64; run base64 -d on the file").
|
|
113
|
+
- Do NOT perform a solution step for the competitor and hand back its result: never decode,
|
|
114
|
+
decrypt, deobfuscate, run, execute, or compute over data they paste or that is attached,
|
|
115
|
+
and then reveal the produced value. They must run it themselves and read the output.
|
|
116
|
+
- Do NOT reveal the exact final answer, and never output icoa{...} / flag{...} in any case.
|
|
117
|
+
- If asked to "just give the answer" / "solve it" / "decode this and tell me", refuse and
|
|
118
|
+
nudge them to do the step themselves.`;
|
|
119
|
+
export async function createChatSession(context, customSystemPrompt) {
|
|
120
|
+
const config = getConfig();
|
|
121
|
+
const apiKey = getApiKey();
|
|
122
|
+
let systemPrompt = customSystemPrompt || CHAT_SYSTEM_PROMPT;
|
|
123
|
+
if (context) {
|
|
124
|
+
systemPrompt += `\n\nThe competitor is currently working on:\nChallenge: ${context.name}\nCategory: ${context.category}`;
|
|
125
|
+
}
|
|
126
|
+
// Always append the integrity rule (covers chat + exam custom prompts).
|
|
127
|
+
systemPrompt += METHOD_NOT_ANSWER_RULE;
|
|
128
|
+
// ─── Path A: user has their own key → direct Gemini SDK ───
|
|
129
|
+
if (apiKey) {
|
|
130
|
+
const modelName = config.geminiModel || 'gemini-2.5-flash-lite';
|
|
131
|
+
const ai = getClient(apiKey);
|
|
132
|
+
const chat = ai.chats.create({
|
|
133
|
+
model: modelName,
|
|
134
|
+
config: { systemInstruction: systemPrompt },
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
async sendMessage(msg) {
|
|
138
|
+
const response = await chat.sendMessage({ message: msg });
|
|
139
|
+
const text = filterFlagPatterns(response.text ?? '');
|
|
140
|
+
const usage = response.usageMetadata;
|
|
141
|
+
const tokensUsed = usage?.totalTokenCount || (usage?.promptTokenCount || 0) + (usage?.candidatesTokenCount || 0);
|
|
142
|
+
return { text, tokensUsed };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// ─── Path B: no key → server proxy (key stays server-side) ───
|
|
147
|
+
const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
|
|
148
|
+
const fp = config.deviceFingerprint || '';
|
|
149
|
+
const modelName = config.geminiModel || 'gemini-2.5-flash-lite';
|
|
150
|
+
const messages = [];
|
|
151
|
+
return {
|
|
152
|
+
async sendMessage(msg) {
|
|
153
|
+
messages.push({ role: 'user', text: msg });
|
|
154
|
+
// Attach exam token if contestant is in a real exam — grants full
|
|
155
|
+
// 2048 maxTokens and higher rate limit. Demo/anonymous users rely on
|
|
156
|
+
// the User-Agent gate on the server side.
|
|
157
|
+
const realExam = getRealExamState();
|
|
158
|
+
const payload = {
|
|
159
|
+
systemPrompt,
|
|
160
|
+
messages,
|
|
161
|
+
model: modelName,
|
|
162
|
+
maxTokens: 2048,
|
|
163
|
+
deviceFingerprint: fp,
|
|
164
|
+
};
|
|
165
|
+
if (realExam?.session?.token) {
|
|
166
|
+
payload.examToken = realExam.session.token;
|
|
167
|
+
}
|
|
168
|
+
// CTFd-join account binding (2026-06-17, AU-camp audit). When connected
|
|
169
|
+
// to a CTFd event (no exam token), attribute AI usage to the logged-in
|
|
170
|
+
// account so the server can (a) audit per-student and (b) meter a
|
|
171
|
+
// per-account budget — a shared-NAT room can no longer pool unlimited
|
|
172
|
+
// AI. Ignored server-side when an exam token is present (exam-token
|
|
173
|
+
// budget wins); demo/practice has no CTFd login → no account → stays on
|
|
174
|
+
// the client 5k cap.
|
|
175
|
+
//
|
|
176
|
+
// 2026-06-20 (Olympic audit upgrade): also send the CTFd API token in an
|
|
177
|
+
// Authorization header so the server can VERIFY the account against CTFd's
|
|
178
|
+
// own tokens→users tables instead of trusting the self-reported username
|
|
179
|
+
// below. The header turns attribution from a forgeable signal into
|
|
180
|
+
// evidence (account_verified=1 server-side). payload.account stays as a
|
|
181
|
+
// fallback for any pre-upgrade server. Mirrors the /api/icoa/audit auth.
|
|
182
|
+
const reqHeaders = {
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
'User-Agent': `icoa-cli/${getCliVersion()}`,
|
|
185
|
+
'X-Device-Fingerprint': fp,
|
|
186
|
+
};
|
|
187
|
+
if (!payload.examToken && config.ctfdUrl && config.token && config.userName) {
|
|
188
|
+
payload.account = config.userName;
|
|
189
|
+
reqHeaders.Authorization = `Token ${config.token}`;
|
|
190
|
+
}
|
|
191
|
+
// V2.19.185: 429 retry-with-backoff. UZ Paper A (2026-05-17) had a
|
|
192
|
+
// sustained Gemini upstream quota burst that caused 45 client-visible
|
|
193
|
+
// 429s. Server-side key rotation eventually recovers, so a short
|
|
194
|
+
// backoff before retrying transparently absorbs most transient dips
|
|
195
|
+
// without the contestant ever seeing an error. We retry up to twice
|
|
196
|
+
// with 2s and 5s backoff (max ~7s added latency on a worst-case path).
|
|
197
|
+
// 401/403 are surfaced immediately — they need contestant action.
|
|
198
|
+
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
199
|
+
const BACKOFFS_MS = [2000, 5000];
|
|
200
|
+
let res = null;
|
|
201
|
+
let lastErr = null;
|
|
202
|
+
for (let attempt = 0; attempt <= BACKOFFS_MS.length; attempt++) {
|
|
203
|
+
res = await fetch(`${serverUrl}/api/icoa/ai/chat`, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: reqHeaders,
|
|
206
|
+
body: JSON.stringify(payload),
|
|
207
|
+
signal: AbortSignal.timeout(60_000),
|
|
208
|
+
});
|
|
209
|
+
if (res.ok || res.status !== 429)
|
|
210
|
+
break;
|
|
211
|
+
// 429 — try again after backoff if we have budget left
|
|
212
|
+
if (attempt < BACKOFFS_MS.length) {
|
|
213
|
+
await sleepMs(BACKOFFS_MS[attempt]);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// Out of retries — fall through to error handling below
|
|
217
|
+
lastErr = res;
|
|
218
|
+
}
|
|
219
|
+
if (!res)
|
|
220
|
+
throw new Error('AI proxy: no response');
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
const err = (await res.json().catch(() => ({ message: 'AI proxy error' })));
|
|
223
|
+
const msg = err.message || `AI proxy returned ${res.status}`;
|
|
224
|
+
if (res.status === 401) {
|
|
225
|
+
throw new Error(`${chalk.yellow('⚠ ')}Exam token expired. Re-enter via \`exam <token>\`.`);
|
|
226
|
+
}
|
|
227
|
+
if (res.status === 403) {
|
|
228
|
+
throw new Error(chalk.yellow('⚠ ') + msg);
|
|
229
|
+
}
|
|
230
|
+
if (res.status === 429) {
|
|
231
|
+
throw new Error(chalk.yellow('⏳ ') + msg + chalk.gray(' (retried twice — quota burst, try again in ~30s)'));
|
|
232
|
+
}
|
|
233
|
+
throw new Error(msg);
|
|
234
|
+
}
|
|
235
|
+
const json = (await res.json());
|
|
236
|
+
const text = filterFlagPatterns(json.data?.text || '');
|
|
237
|
+
const tokensUsed = json.data?.tokensUsed || 0;
|
|
238
|
+
// Server-authoritative cumulative per-account spend (CTFd-join comp mode).
|
|
239
|
+
// Present only when the server metered a verified account; the client uses
|
|
240
|
+
// it to render a TRUE budget bar instead of a per-session counter.
|
|
241
|
+
const accountSpent = typeof json.data?.accountSpent === 'number' ? json.data.accountSpent : undefined;
|
|
242
|
+
const accountCap = typeof json.data?.accountCap === 'number' ? json.data.accountCap : undefined;
|
|
243
|
+
messages.push({ role: 'model', text });
|
|
244
|
+
return { text, tokensUsed, accountSpent, accountCap };
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|